mirror of
https://github.com/certd/certd.git
synced 2026-10-14 09:55:57 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14809127d6 | ||
|
|
bd8b251d6e | ||
|
|
5563abb13a | ||
|
|
51e83e4c38 | ||
|
|
2b1a5296fe | ||
|
|
bded368cbb | ||
|
|
6a649aaca0 | ||
|
|
e971145686 | ||
|
|
bbca449f94 | ||
|
|
e6852c2715 | ||
|
|
47cb523307 | ||
|
|
cbf46b5c48 | ||
|
|
7ed3105d97 | ||
|
|
61b945c08f | ||
|
|
cc60cd4cd1 | ||
|
|
dade599acb | ||
|
|
1070417a36 | ||
|
|
81f676f1ca | ||
|
|
0cf0608d3f | ||
|
|
dac94b64f0 | ||
|
|
8c88092235 | ||
|
|
2a2e963914 | ||
|
|
1b882a2ca4 | ||
|
|
bd9433478a | ||
|
|
32ade8e4a3 | ||
|
|
f2bcdeeb33 | ||
|
|
4099af7f5d |
@@ -75,7 +75,7 @@ Certd 是可私有化部署的 SSL/TLS 证书自动化管理平台,提供 Web
|
||||
- 只有真正机械化的大批量替换、格式化或生成任务才考虑脚本/工具。若必须使用临时脚本,应放在临时目录并在同一个受控步骤内完成创建、执行、删除;不要把临时脚本落在仓库里跨多步工具调用执行。
|
||||
- 不要主动运行 `pnpm install`;缺依赖、TTY、网络导致安装或测试失败时,停止尝试并说明环境问题。
|
||||
- 优先沿用现有模块、插件、service、页面模式;不要为形式上的复用制造过度抽象。
|
||||
- 代码可读性优先于短写法。尽量少用三元表达式,优先使用完整的 `if/else`;复杂条件、链式调用、内联对象和多层 helper 调用要拆成命名清晰的中间变量或小方法。
|
||||
- 【重要重要重要】代码可读性优先于短写法。尽量少用三元表达式,优先使用完整的 `if/else`【其他技能与此条要求冲突的,以此条要求为准】;复杂条件、链式调用、内联对象和多层 helper 调用要拆成命名清晰的中间变量或小方法。
|
||||
- 方法调用链不要直接塞进另一个方法参数;先用有意义的局部变量承接返回值,再传入下一步。
|
||||
- 不要在单一表达式内嵌套分支、对象构造与方法调用。优先使用清晰的 `if/else` 分支;仅在确实能降低复杂度时才提取有意义的中间变量,避免为拆分而增加阅读跳转。
|
||||
- 注释优先使用中文,尤其是业务规则、兼容逻辑、协议细节和隐藏风险;文件已有英文风格或引用外部术语时可保持一致。
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复添加步骤会修改所有步骤标题的bug ([51e83e4](https://github.com/certd/certd/commit/51e83e4c38f7f319314fdc38ef33cf5720b2dde0))
|
||||
* 修复证书插件配置无法打开的bug ([5563abb](https://github.com/certd/certd/commit/5563abb13a62140f4dee668ab768cf4ff9371ddb))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 新增余额管理,管理员充值功能 ([bded368](https://github.com/certd/certd/commit/bded368cbb8974b6937ae260e448c25fae5c33ad))
|
||||
* **acme-account-access:** AcmeAccount授权允许修改eab,重新生成account ([6a649aa](https://github.com/certd/certd/commit/6a649aaca083ccd3b517f7d676a58220aa33b37d))
|
||||
* **ui:** 商业版套餐购买不要求设置支付方式 ([e971145](https://github.com/certd/certd/commit/e9711456860d7db40914a0c295534f7bca56f83c))
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
|
||||
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -105,14 +105,17 @@ https://certd.handfree.work/
|
||||
|
||||
#### Docker镜像说明:
|
||||
|
||||
##### 1. 镜像地址格式:
|
||||
##### 1. 镜像地址:
|
||||
|
||||
```
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
|
||||
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型 (中括号表示可选)
|
||||
| 镜像仓库 | 最新预览版 | 稳定版 | slim版 | armv7版 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
|
||||
| Docker Hub | `certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
|
||||
|
||||
```
|
||||
##### 2. 版本标签:
|
||||
> 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
|
||||
|
||||
##### 2. 版本标签说明:
|
||||
|
||||
**最新版本标签:**
|
||||
|
||||
@@ -131,16 +134,8 @@ registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
|
||||
| slim | `certd:slim` | Debian slim | 基于glibc,dns解析兼容性好 | `certd:slim-stable` | `certd:1.43.0-slim` |
|
||||
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` | `certd:1.43.0-armv7` |
|
||||
|
||||
##### 3. 镜像地址:
|
||||
|
||||
| 镜像仓库 | 最新预览版 | slim | armv7 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
| Docker Hub | `certd/certd:latest` | `certd:slim` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
|
||||
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
|
||||
> 2. 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
|
||||
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
|
||||
|
||||
最新版本号:
|
||||

|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
|
||||
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复插件市场插件安装无效的bug ([15d2bd8](https://github.com/certd/certd/commit/15d2bd8484a9ddd142bf4ef03d839c74405f9e6d))
|
||||
* preserve Route53 TXT record values ([#791](https://github.com/certd/certd/issues/791)) ([f5430a8](https://github.com/certd/certd/commit/f5430a8925cf6cf4e16c75f2358fbd78437e6705))
|
||||
|
||||
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -6,13 +6,17 @@
|
||||
|
||||
Certd 提供多种 Docker 镜像版本,您可以根据需要选择:
|
||||
|
||||
#### 1. 镜像地址格式:
|
||||
#### 1. 镜像地址:
|
||||
|
||||
```
|
||||
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
|
||||
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型
|
||||
```
|
||||
#### 2. 版本标签:
|
||||
| 镜像仓库 | 最新预览版 | 稳定版 | slim版 | armv7版 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
|
||||
| Docker Hub | `certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
|
||||
|
||||
> 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
|
||||
|
||||
#### 2. 版本标签说明:
|
||||
|
||||
**最新版本标签:**
|
||||
|
||||
@@ -25,22 +29,14 @@ registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
|
||||
|
||||
> 根据基础镜像不同,分为如下三个分支版本,没有特殊需求选择默认的即可(他们功能是一样的)
|
||||
|
||||
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 | 指定版本 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小,支持x86、ARM架构 | `certd:stable` | `certd:1.43.0` |
|
||||
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 | 指定版本 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小 | `certd:stable` | `certd:1.43.0` |
|
||||
| slim | `certd:slim` | Debian slim | 基于glibc,dns解析兼容性好 | `certd:slim-stable` | `certd:1.43.0-slim` |
|
||||
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` | `certd:1.43.0-armv7` |
|
||||
|
||||
#### 3. 镜像地址:
|
||||
|
||||
| 镜像仓库 | 最新预览版 | slim版 | armv7版 |
|
||||
| --- | --- | --- | --- |
|
||||
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
| Docker Hub | `certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
|
||||
|
||||
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
|
||||
> 2. 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
|
||||
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
|
||||
|
||||
最新版本号:
|
||||

|
||||
|
||||
@@ -36,52 +36,53 @@
|
||||
| 32.| **Dokploy授权** | |
|
||||
| 33.| **Dynadot授权** | |
|
||||
| 34.| **farcdn授权** | |
|
||||
| 35.| **FlexCDN授权** | |
|
||||
| 36.| **Gcore** | Gcore |
|
||||
| 37.| **Github授权** | |
|
||||
| 38.| **godaddy授权** | |
|
||||
| 39.| **HiPM DNSMgr** | HiPM DNSMgr API Token 授权 |
|
||||
| 40.| **金山云授权** | |
|
||||
| 41.| **FTP授权** | |
|
||||
| 42.| **七牛OSS授权** | |
|
||||
| 43.| **腾讯云COS授权** | 腾讯云对象存储授权,包含地域和存储桶 |
|
||||
| 44.| **s3/minio授权** | S3/minio oss授权 |
|
||||
| 45.| **namesilo授权** | |
|
||||
| 46.| **Next Terminal 授权** | 用于访问 Next Terminal API 的授权配置 |
|
||||
| 47.| **Nginx Proxy Manager 授权** | 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。 |
|
||||
| 48.| **1panel授权** | 账号和密码 |
|
||||
| 49.| **支付宝** | |
|
||||
| 50.| **白山云授权** | |
|
||||
| 51.| **宝塔云WAF授权** | 用于连接和管理宝塔云WAF服务的授权配置 |
|
||||
| 52.| **cdnfly授权** | |
|
||||
| 53.| **k8s授权** | |
|
||||
| 54.| **括彩云cdn授权** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 55.| **LeCDN授权** | |
|
||||
| 56.| **lucky** | |
|
||||
| 57.| **猫云授权** | |
|
||||
| 58.| **plesk授权** | |
|
||||
| 59.| **长亭雷池授权** | |
|
||||
| 60.| **群晖登录授权** | |
|
||||
| 61.| **uniCloud** | unicloud授权 |
|
||||
| 62.| **微信支付** | |
|
||||
| 63.| **易盾rcdn授权** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 64.| **易发云短信** | sms.yfyidc.cn/ |
|
||||
| 65.| **易盾DCDN授权** | https://user.yiduncdn.com |
|
||||
| 66.| **易支付** | |
|
||||
| 67.| **proxmox** | |
|
||||
| 68.| **Spaceship.com 授权** | Spaceship.com API 授权插件 |
|
||||
| 69.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器授权 |
|
||||
| 70.| **UCloud授权** | 优刻得授权 |
|
||||
| 71.| **又拍云** | |
|
||||
| 72.| **网宿授权** | |
|
||||
| 73.| **西部数码授权** | |
|
||||
| 74.| **我爱云授权** | 我爱云CDN |
|
||||
| 75.| **新网授权(代理方式)** | |
|
||||
| 76.| **新网授权** | |
|
||||
| 77.| **新网互联授权** | 仅支持代理账号,ip需要加入白名单 |
|
||||
| 78.| **Zenlayer授权** | Zenlayer授权 |
|
||||
| 79.| **GoEdge授权** | |
|
||||
| 80.| **雨云授权** | https://app.rainyun.com/ |
|
||||
| 35.| **Fastly授权** | Fastly CDN / TLS Custom Certificates API 授权 |
|
||||
| 36.| **FlexCDN授权** | |
|
||||
| 37.| **Gcore** | Gcore |
|
||||
| 38.| **Github授权** | |
|
||||
| 39.| **godaddy授权** | |
|
||||
| 40.| **HiPM DNSMgr** | HiPM DNSMgr API Token 授权 |
|
||||
| 41.| **金山云授权** | |
|
||||
| 42.| **FTP授权** | |
|
||||
| 43.| **七牛OSS授权** | |
|
||||
| 44.| **腾讯云COS授权** | 腾讯云对象存储授权,包含地域和存储桶 |
|
||||
| 45.| **s3/minio授权** | S3/minio oss授权 |
|
||||
| 46.| **namesilo授权** | |
|
||||
| 47.| **Next Terminal 授权** | 用于访问 Next Terminal API 的授权配置 |
|
||||
| 48.| **Nginx Proxy Manager 授权** | 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。 |
|
||||
| 49.| **1panel授权** | 账号和密码 |
|
||||
| 50.| **支付宝** | |
|
||||
| 51.| **白山云授权** | |
|
||||
| 52.| **宝塔云WAF授权** | 用于连接和管理宝塔云WAF服务的授权配置 |
|
||||
| 53.| **cdnfly授权** | |
|
||||
| 54.| **k8s授权** | |
|
||||
| 55.| **括彩云cdn授权** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 56.| **LeCDN授权** | |
|
||||
| 57.| **lucky** | |
|
||||
| 58.| **猫云授权** | |
|
||||
| 59.| **plesk授权** | |
|
||||
| 60.| **长亭雷池授权** | |
|
||||
| 61.| **群晖登录授权** | |
|
||||
| 62.| **uniCloud** | unicloud授权 |
|
||||
| 63.| **微信支付** | |
|
||||
| 64.| **易盾rcdn授权** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 65.| **易发云短信** | sms.yfyidc.cn/ |
|
||||
| 66.| **易盾DCDN授权** | https://user.yiduncdn.com |
|
||||
| 67.| **易支付** | |
|
||||
| 68.| **proxmox** | |
|
||||
| 69.| **Spaceship.com 授权** | Spaceship.com API 授权插件 |
|
||||
| 70.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器授权 |
|
||||
| 71.| **UCloud授权** | 优刻得授权 |
|
||||
| 72.| **又拍云** | |
|
||||
| 73.| **网宿授权** | |
|
||||
| 74.| **西部数码授权** | |
|
||||
| 75.| **我爱云授权** | 我爱云CDN |
|
||||
| 76.| **新网授权(代理方式)** | |
|
||||
| 77.| **新网授权** | |
|
||||
| 78.| **新网互联授权** | 仅支持代理账号,ip需要加入白名单 |
|
||||
| 79.| **Zenlayer授权** | Zenlayer授权 |
|
||||
| 80.| **GoEdge授权** | |
|
||||
| 81.| **雨云授权** | https://app.rainyun.com/ |
|
||||
|
||||
<style module>
|
||||
table th:first-of-type {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 任务插件
|
||||
共 `135` 款任务插件
|
||||
共 `139` 款任务插件
|
||||
## 1. 证书申请
|
||||
|
||||
| 序号 | 名称 | 说明 |
|
||||
@@ -31,26 +31,30 @@
|
||||
| 4.| **中国移动-部署证书到CDN** | 中国移动自动部署证书到CDN |
|
||||
| 5.| **多吉云-部署到多吉云CDN** | |
|
||||
| 6.| **farcdn-更新证书** | www.farcdn.net |
|
||||
| 7.| **FlexCDN-更新证书** | |
|
||||
| 8.| **Gcore-刷新Gcore证书** | 刷新现有的证书 |
|
||||
| 9.| **Gcore-部署证书到Gcore** | 仅上传 并不会部署到cdn |
|
||||
| 10.| **GoEdge-更新证书** | GoEdge |
|
||||
| 11.| **金山云-更新CDN证书** | 金山云自动更新CDN证书 |
|
||||
| 12.| **白山云-更新证书** | |
|
||||
| 13.| **cdnfly-部署证书到cdnfly** | cdnfly |
|
||||
| 14.| **天翼云-部署证书到CDN** | 部署证书到天翼云CDN和全站加速 |
|
||||
| 15.| **括彩云-部署到括彩云CDN** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 16.| **LeCDN-更新证书V2** | 支持新版本LeCDN |
|
||||
| 17.| **LeCDN-更新证书** | |
|
||||
| 18.| **Maoyun-更新猫云CDN证书** | |
|
||||
| 19.| **易盾-部署到易盾DCDN** | 主要是防御,http://user.yiduncdn.com/ |
|
||||
| 20.| **易盾-部署到易盾RCDN** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 21.| **雨云-更新证书** | app.rainyun.com |
|
||||
| 22.| **又拍云-部署证书到CDN/USS** | 支持又拍云CDN,又拍云云存储USS |
|
||||
| 23.| **网宿-更新证书** | 网宿证书自动更新 |
|
||||
| 24.| **西数-部署到虚拟主机** | 西部数码部署证书到虚拟主机 |
|
||||
| 25.| **我爱云-部署证书到我爱云CDN** | 部署证书到我爱云CDN |
|
||||
| 26.| **Zenlayer-刷新证书** | 刷新Zenlayer CDN证书 |
|
||||
| 7.| **Fastly-部署TLS激活** | 部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名) |
|
||||
| 8.| **Fastly-清除缓存** | 清除 Fastly 指定 Service 的所有缓存 (Purge All) |
|
||||
| 9.| **Fastly-更新证书** | 自动更新 Fastly CDN 上的已有自定义证书 |
|
||||
| 10.| **Fastly-上传证书到Fastly** | 部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN |
|
||||
| 11.| **FlexCDN-更新证书** | |
|
||||
| 12.| **Gcore-刷新Gcore证书** | 刷新现有的证书 |
|
||||
| 13.| **Gcore-部署证书到Gcore** | 仅上传 并不会部署到cdn |
|
||||
| 14.| **GoEdge-更新证书** | GoEdge |
|
||||
| 15.| **金山云-更新CDN证书** | 金山云自动更新CDN证书 |
|
||||
| 16.| **白山云-更新证书** | |
|
||||
| 17.| **cdnfly-部署证书到cdnfly** | cdnfly |
|
||||
| 18.| **天翼云-部署证书到CDN** | 部署证书到天翼云CDN和全站加速 |
|
||||
| 19.| **括彩云-部署到括彩云CDN** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 20.| **LeCDN-更新证书V2** | 支持新版本LeCDN |
|
||||
| 21.| **LeCDN-更新证书** | |
|
||||
| 22.| **Maoyun-更新猫云CDN证书** | |
|
||||
| 23.| **易盾-部署到易盾DCDN** | 主要是防御,http://user.yiduncdn.com/ |
|
||||
| 24.| **易盾-部署到易盾RCDN** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
|
||||
| 25.| **雨云-更新证书** | app.rainyun.com |
|
||||
| 26.| **又拍云-部署证书到CDN/USS** | 支持又拍云CDN,又拍云云存储USS |
|
||||
| 27.| **网宿-更新证书** | 网宿证书自动更新 |
|
||||
| 28.| **西数-部署到虚拟主机** | 西部数码部署证书到虚拟主机 |
|
||||
| 29.| **我爱云-部署证书到我爱云CDN** | 部署证书到我爱云CDN |
|
||||
| 30.| **Zenlayer-刷新证书** | 刷新Zenlayer CDN证书 |
|
||||
## 4. 面板
|
||||
|
||||
| 序号 | 名称 | 说明 |
|
||||
|
||||
+1
-1
@@ -9,5 +9,5 @@
|
||||
}
|
||||
},
|
||||
"npmClient": "pnpm",
|
||||
"version": "1.44.2"
|
||||
"version": "1.44.4"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/publishlab/node-acme-client/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.44.3](https://github.com/publishlab/node-acme-client/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
## [1.44.2](https://github.com/publishlab/node-acme-client/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/acme-client
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"description": "Simple and unopinionated ACME client",
|
||||
"private": false,
|
||||
"author": "nmorsman",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"module": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
@@ -18,7 +18,7 @@
|
||||
"types"
|
||||
],
|
||||
"dependencies": {
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/basic": "^1.44.4",
|
||||
"@peculiar/x509": "^1.11.0",
|
||||
"asn1js": "^3.0.5",
|
||||
"axios": "^1.9.0",
|
||||
@@ -76,5 +76,5 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/publishlab/node-acme-client/issues"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/basic
|
||||
|
||||
@@ -1 +1 @@
|
||||
21:57
|
||||
19:17
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/basic",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -54,5 +54,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/pipeline
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/pipeline
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/pipeline
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/pipeline",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -21,8 +21,8 @@
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/plus-core": "^1.44.2",
|
||||
"@certd/basic": "^1.44.4",
|
||||
"@certd/plus-core": "^1.44.4",
|
||||
"dayjs": "^1.11.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
@@ -51,5 +51,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-huawei
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/lib-huawei",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"main": "./dist/bundle.js",
|
||||
"module": "./dist/bundle.js",
|
||||
"types": "./dist/d/index.d.ts",
|
||||
@@ -31,5 +31,5 @@
|
||||
"prettier": "3.3.3",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-iframe
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/lib-iframe",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -37,5 +37,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/jdcloud
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/jdcloud",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"description": "jdcloud openApi sdk",
|
||||
"main": "./dist/bundle.js",
|
||||
"module": "./dist/bundle.js",
|
||||
@@ -63,5 +63,5 @@
|
||||
"fetch"
|
||||
]
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-k8s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/lib-k8s",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
@@ -21,7 +21,7 @@
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/basic": "^1.44.4",
|
||||
"@kubernetes/client-node": "0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,5 +38,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复证书插件配置无法打开的bug ([5563abb](https://github.com/certd/certd/commit/5563abb13a62140f4dee668ab768cf4ff9371ddb))
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-server
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/lib-server
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/lib-server",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"description": "midway with flyway, sql upgrade way ",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
@@ -29,11 +29,11 @@
|
||||
],
|
||||
"license": "AGPL",
|
||||
"dependencies": {
|
||||
"@certd/acme-client": "^1.44.2",
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/pipeline": "^1.44.2",
|
||||
"@certd/plugin-lib": "^1.44.2",
|
||||
"@certd/plus-core": "^1.44.2",
|
||||
"@certd/acme-client": "^1.44.4",
|
||||
"@certd/basic": "^1.44.4",
|
||||
"@certd/pipeline": "^1.44.4",
|
||||
"@certd/plugin-lib": "^1.44.4",
|
||||
"@certd/plus-core": "^1.44.4",
|
||||
"@midwayjs/cache": "3.14.0",
|
||||
"@midwayjs/core": "3.20.11",
|
||||
"@midwayjs/i18n": "3.20.13",
|
||||
@@ -69,5 +69,5 @@
|
||||
"typeorm": "^0.3.20",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -133,12 +133,12 @@ export class SysPrivateSettings extends BaseSettings {
|
||||
// 内置项不在此配置 Directory URL:其端点由运行时按加密算法(pkType)通过 acme.getDirectoryUrl 获取,
|
||||
// 例如 ZeroSSL、SSL.com 的 RSA 与 EC 证书使用不同的端点,写死单一 URL 会导致 EC 申请走错端点。
|
||||
customAcmeProviders?: CustomAcmeProvider[] = [
|
||||
{ sslProvider: "letsencrypt", title: "Let's Encrypt", directoryUrl: "", needEAB: false, builtIn: true },
|
||||
{ sslProvider: "letsencrypt_staging", title: "Let's Encrypt测试环境", directoryUrl: "", needEAB: false, builtIn: true },
|
||||
{ sslProvider: "letsencrypt", title: "Let's Encrypt(支持7天IP证书)", directoryUrl: "", needEAB: false, builtIn: true },
|
||||
{ sslProvider: "google", title: "Google", directoryUrl: "", needEAB: true, builtIn: true },
|
||||
{ sslProvider: "zerossl", title: "ZeroSSL", directoryUrl: "", needEAB: true, builtIn: true },
|
||||
{ sslProvider: "litessl", title: "litessl", directoryUrl: "", needEAB: true, builtIn: true },
|
||||
{ sslProvider: "litessl", title: "Litessl", directoryUrl: "", needEAB: true, builtIn: true },
|
||||
{ sslProvider: "sslcom", title: "SSL.com", directoryUrl: "", needEAB: true, builtIn: true },
|
||||
{ sslProvider: "letsencrypt_staging", title: "Let's Encrypt测试环境(只能生成测试证书)", directoryUrl: "", needEAB: false, builtIn: true },
|
||||
];
|
||||
|
||||
removeSecret() {
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/midway-flyway-js
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/midway-flyway-js
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/midway-flyway-js
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/midway-flyway-js",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"description": "midway with flyway, sql upgrade way ",
|
||||
"private": false,
|
||||
"type": "module",
|
||||
@@ -52,5 +52,5 @@
|
||||
"typeorm": "^0.3.20",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/plugin-cert
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/plugin-cert
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/plugin-cert
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/plugin-cert",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -20,7 +20,7 @@
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/plugin-lib": "^1.44.2"
|
||||
"@certd/plugin-lib": "^1.44.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.12",
|
||||
@@ -38,5 +38,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
**Note:** Version bump only for package @certd/plugin-lib
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
**Note:** Version bump only for package @certd/plugin-lib
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
**Note:** Version bump only for package @certd/plugin-lib
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@certd/plugin-lib",
|
||||
"private": false,
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
@@ -17,9 +17,9 @@
|
||||
"lint": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/acme-client": "^1.44.2",
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/pipeline": "^1.44.2",
|
||||
"@certd/acme-client": "^1.44.4",
|
||||
"@certd/basic": "^1.44.4",
|
||||
"@certd/pipeline": "^1.44.4",
|
||||
"dayjs": "^1.11.7",
|
||||
"jszip": "^3.10.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -45,5 +45,5 @@
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.4.2"
|
||||
},
|
||||
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
|
||||
"gitHead": "cc60cd4cd1d914110be65284bcf16cd3c5a63b8d"
|
||||
}
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 修复添加步骤会修改所有步骤标题的bug ([51e83e4](https://github.com/certd/certd/commit/51e83e4c38f7f319314fdc38ef33cf5720b2dde0))
|
||||
* 修复证书插件配置无法打开的bug ([5563abb](https://github.com/certd/certd/commit/5563abb13a62140f4dee668ab768cf4ff9371ddb))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 新增余额管理,管理员充值功能 ([bded368](https://github.com/certd/certd/commit/bded368cbb8974b6937ae260e448c25fae5c33ad))
|
||||
* **ui:** 商业版套餐购买不要求设置支付方式 ([e971145](https://github.com/certd/certd/commit/e9711456860d7db40914a0c295534f7bca56f83c))
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/ui-client",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --open",
|
||||
@@ -104,8 +104,8 @@
|
||||
"zod-defaults": "^0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@certd/lib-iframe": "^1.44.2",
|
||||
"@certd/pipeline": "^1.44.2",
|
||||
"@certd/lib-iframe": "^1.44.4",
|
||||
"@certd/pipeline": "^1.44.4",
|
||||
"@rollup/plugin-commonjs": "^25.0.7",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@types/chai": "^4.3.12",
|
||||
|
||||
@@ -53,6 +53,8 @@ export default {
|
||||
suiteSetting: "Suite Settings",
|
||||
orderManager: "Order Management",
|
||||
userSuites: "User Suites",
|
||||
balanceManager: "Balance Management",
|
||||
balanceLog: "Balance Change Records",
|
||||
inviteCommissionSetting: "Incentive Plan Settings",
|
||||
inviteLevel: "Promotion Levels",
|
||||
inviteUserLevel: "User Promotion Levels",
|
||||
|
||||
@@ -54,6 +54,8 @@ export default {
|
||||
suiteSetting: "套餐设置",
|
||||
orderManager: "订单管理",
|
||||
userSuites: "用户套餐",
|
||||
balanceManager: "余额管理",
|
||||
balanceLog: "余额变更记录",
|
||||
inviteCommissionSetting: "激励计划设置",
|
||||
inviteLevel: "推广等级",
|
||||
inviteUserLevel: "用户推广等级",
|
||||
|
||||
@@ -317,6 +317,34 @@ export const sysResources = [
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "certd.sysResources.balanceManager",
|
||||
name: "SysWallet",
|
||||
path: "/sys/suite/wallet",
|
||||
component: "/sys/suite/wallet/index.vue",
|
||||
meta: {
|
||||
show: () => {
|
||||
const settingStore = useSettingStore();
|
||||
return settingStore.isComm;
|
||||
},
|
||||
icon: "ion:wallet-outline",
|
||||
permission: "sys:settings:edit",
|
||||
auth: true,
|
||||
keepAlive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "certd.sysResources.balanceLog",
|
||||
name: "SysWalletLog",
|
||||
path: "/sys/suite/wallet-log",
|
||||
component: "/sys/suite/wallet/log/index.vue",
|
||||
meta: {
|
||||
isMenu: false,
|
||||
permission: "sys:settings:view",
|
||||
auth: true,
|
||||
keepAlive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "certd.sysResources.inviteCommissionSetting",
|
||||
name: "SysInviteCommissionSetting",
|
||||
|
||||
@@ -26,6 +26,14 @@ export function fillPluginDefine(plugin: any) {
|
||||
|
||||
if (plugin.sysSetting?.metadata) {
|
||||
merge(plugin.input, plugin.sysSetting.metadata?.input || {});
|
||||
const keys = Object.keys(plugin.input);
|
||||
for (const key of keys) {
|
||||
//修复旧版本错误引入的key导致报错的问题 【多引入进了一个type=CertApply】
|
||||
const column = plugin.input[key];
|
||||
if (typeof column != "object") {
|
||||
delete plugin.input[key];
|
||||
}
|
||||
}
|
||||
// 应用选项映射
|
||||
for (const key of Object.keys(plugin.input)) {
|
||||
const inputDef = plugin.input[key];
|
||||
|
||||
@@ -51,10 +51,11 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
|
||||
rowHandle: {
|
||||
width: 120,
|
||||
fixed: "right",
|
||||
show: false,
|
||||
buttons: {
|
||||
view: { show: false },
|
||||
edit: { show: false },
|
||||
remove: { show: true },
|
||||
remove: { show: false },
|
||||
copy: { show: false },
|
||||
},
|
||||
},
|
||||
|
||||
+4
-1
@@ -280,7 +280,10 @@ function useStepForm() {
|
||||
return;
|
||||
}
|
||||
|
||||
callback.value("save", currentStep);
|
||||
// currentStep 是复用同一份 reactive 对象,直接传给回调会被 task.steps 保存同一引用,
|
||||
// 后续再次 stepOpen 会 delete 掉其属性,导致已保存的步骤(如第一个步骤)被覆盖。
|
||||
// 这里深拷贝一份再传出,保证每个步骤是独立对象。
|
||||
callback.value("save", cloneDeep(currentStep));
|
||||
stepDrawerClose();
|
||||
};
|
||||
|
||||
|
||||
@@ -53,9 +53,14 @@
|
||||
|
||||
<div class="flex-o mt-5">
|
||||
<span class="label">{{ $t("certd.order.paymentMethod") }}:</span>
|
||||
<div v-if="durationSelected.price === 0 || expectedThirdPartyAmount === 0">{{ $t("certd.order.free") }}</div>
|
||||
<div v-if="durationSelected.price === 0 || expectedThirdPartyAmount === 0">0元</div>
|
||||
<div v-else-if="noPaymentMethod">当前仅支持余额支付</div>
|
||||
<fs-dict-select v-else v-model:value="formRef.payType" :dict="paymentsDictRef" style="width: 200px"> </fs-dict-select>
|
||||
</div>
|
||||
|
||||
<div v-if="showBalanceInsufficientTip" class="flex-o mt-5">
|
||||
<span class="color-red">余额不足,请联系管理员,充值或设置套餐</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -88,12 +93,17 @@ async function open(opts: OrderModalOpenReq) {
|
||||
formRef.value.productId = opts.product.id;
|
||||
formRef.value.duration = opts.duration;
|
||||
formRef.value.num = opts.num ?? 1;
|
||||
formRef.value.useRebateBalance = false;
|
||||
formRef.value.useRebateBalance = true;
|
||||
try {
|
||||
wallet.value = await GetWalletSummary();
|
||||
} catch (e) {
|
||||
wallet.value = { availableAmount: 0 };
|
||||
}
|
||||
try {
|
||||
await paymentsDictRef.loadDict();
|
||||
} catch (e) {
|
||||
// 支付方式加载失败时按无可用支付方式处理
|
||||
}
|
||||
}
|
||||
const paymentsDictRef = dict({
|
||||
async getData() {
|
||||
@@ -119,6 +129,23 @@ const expectedThirdPartyAmount = computed(() => {
|
||||
return Math.max(0, (durationSelected.value?.price || 0) - expectedRebateAmount.value);
|
||||
});
|
||||
|
||||
const noPaymentMethod = computed(() => {
|
||||
return paymentsDictRef.data.length === 0;
|
||||
});
|
||||
|
||||
const balanceInsufficient = computed(() => {
|
||||
const price = durationSelected.value?.price || 0;
|
||||
if (price <= 0) {
|
||||
return false;
|
||||
}
|
||||
const availableAmount = wallet.value?.availableAmount || 0;
|
||||
return availableAmount < price;
|
||||
});
|
||||
|
||||
const showBalanceInsufficientTip = computed(() => {
|
||||
return noPaymentMethod.value && balanceInsufficient.value;
|
||||
});
|
||||
|
||||
function amountToYuan(amount: number) {
|
||||
return util.amount.toYuan(amount || 0);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,8 @@ const onFinishFailed = (errorInfo: any) => {
|
||||
const { openConfigDialog } = usePluginConfig();
|
||||
|
||||
async function doPluginConfig() {
|
||||
const certApplyInfo = await getPluginSetting("CertApply");
|
||||
const certApplyInfo: any = await getPluginSetting("CertApply");
|
||||
certApplyInfo.pluginType = "plugin";
|
||||
await openConfigDialog({
|
||||
row: certApplyInfo,
|
||||
onSuccess: async () => {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<script setup lang="tsx">
|
||||
import { dict, FsRender } from "@fast-crud/fast-crud";
|
||||
import { cloneDeep, merge, unset } from "lodash-es";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onMounted, provide, reactive, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import Rollbackable from "./rollbackable.vue";
|
||||
import { fillPluginDefine, usePluginStore } from "/@/store/plugin";
|
||||
@@ -185,9 +185,11 @@ const optionsMappingKey = {
|
||||
},
|
||||
editRender(item: any) {
|
||||
return () => {
|
||||
const options = item.component?.options || [];
|
||||
if (options.length === 0) {
|
||||
return <span class="text-gray-400">该组件没有预设选项</span>;
|
||||
const options = reactive(cloneDeep(item.component?.options || []));
|
||||
|
||||
const showAdd = options.length === 0;
|
||||
function addOption(opt: any) {
|
||||
options.push(opt);
|
||||
}
|
||||
|
||||
const onLabelChange = (optValue: string, newLabel: string) => {
|
||||
@@ -233,6 +235,13 @@ const optionsMappingKey = {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{showAdd && (
|
||||
<div class="mt-2">
|
||||
<a-button type="primary" size={() => addOption({ value: "", label: "" })}>
|
||||
添加选项
|
||||
</a-button>
|
||||
</div>
|
||||
)}
|
||||
<div class="helper mt-1">只需填写需要自定义的选项,留空则使用原始显示内容</div>
|
||||
</div>
|
||||
);
|
||||
@@ -289,6 +298,16 @@ onMounted(async () => {
|
||||
defineExpose({
|
||||
getForm,
|
||||
});
|
||||
|
||||
provide("getCurrentPluginDefine", () => {
|
||||
return { value: props.plugin };
|
||||
});
|
||||
provide("get:scope", () => {
|
||||
return getScope();
|
||||
});
|
||||
provide("get:plugin:type", () => {
|
||||
return props.plugin.pluginType;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
@@ -49,7 +49,7 @@ export function usePluginConfig() {
|
||||
}
|
||||
}
|
||||
const res = await api.savePluginSetting({
|
||||
name: row.fullName,
|
||||
name: row.fullName || row.name,
|
||||
sysSetting: {
|
||||
metadata: {
|
||||
input: newForm,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template #header>
|
||||
<div class="title">
|
||||
套餐设置
|
||||
<span class="sub"> 需要<router-link :to="{ path: '/sys/settings', query: { tab: 'payment' } }">开启至少一种支付方式</router-link></span>
|
||||
<span class="sub"> 建议<router-link :to="{ path: '/sys/settings', query: { tab: 'payment' } }">开启至少一种支付方式</router-link></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<a-form-item label=" " :colon="false">
|
||||
<loading-button type="primary" html-type="button" :click="onClick">保存</loading-button>
|
||||
<div class="helper">需要 <router-link :to="{ path: '/sys/settings', query: { tab: 'payment' } }">开启至少一种支付方式</router-link></div>
|
||||
<div class="helper">建议 <router-link :to="{ path: '/sys/settings', query: { tab: 'payment' } }">开启至少一种支付方式</router-link></div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { request } from "/src/api/service";
|
||||
|
||||
const apiPrefix = "/sys/wallet";
|
||||
|
||||
export async function GetList(query: any) {
|
||||
return await request({
|
||||
url: apiPrefix + "/page",
|
||||
method: "post",
|
||||
data: query,
|
||||
});
|
||||
}
|
||||
|
||||
export async function Recharge(data: { userId: number; amount: number; remark?: string }) {
|
||||
return await request({
|
||||
url: apiPrefix + "/recharge",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GetWalletLogs(query: any) {
|
||||
return await request({
|
||||
url: apiPrefix + "/log/page",
|
||||
method: "post",
|
||||
data: query,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GetSimpleUserByIds(ids: number[]) {
|
||||
return await request({
|
||||
url: "/sys/authority/user/getSimpleUserByIds",
|
||||
method: "post",
|
||||
data: { ids },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { CreateCrudOptionsProps, CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
||||
import { notification } from "ant-design-vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { util } from "/@/utils";
|
||||
import { useFormDialog } from "/@/use/use-dialog";
|
||||
import createCrudOptionsUser from "/@/views/sys/authority/user/crud";
|
||||
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
|
||||
import * as api from "./api";
|
||||
|
||||
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
|
||||
const { openFormDialog } = useFormDialog();
|
||||
const router = useRouter();
|
||||
|
||||
const userDict = dict({
|
||||
async getNodesByValues(ids: number[]) {
|
||||
return await api.GetSimpleUserByIds(ids);
|
||||
},
|
||||
value: "id",
|
||||
label: "nickName",
|
||||
});
|
||||
|
||||
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
|
||||
return await api.GetList(query);
|
||||
};
|
||||
|
||||
function moneyColumn(title: string) {
|
||||
return {
|
||||
title,
|
||||
type: "number",
|
||||
column: {
|
||||
width: 120,
|
||||
component: {
|
||||
name: PriceInput,
|
||||
vModel: "modelValue",
|
||||
edit: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openRecharge(userId?: number) {
|
||||
await openFormDialog({
|
||||
title: "用户余额充值",
|
||||
wrapper: { width: 520 },
|
||||
initialForm: {
|
||||
userId: userId ?? null,
|
||||
amountYuan: null,
|
||||
remark: "",
|
||||
},
|
||||
columns: {
|
||||
userId: {
|
||||
title: "用户",
|
||||
type: "table-select",
|
||||
dict: userDict,
|
||||
form: {
|
||||
col: { span: 24 },
|
||||
rules: [{ required: true, message: "请选择用户" }],
|
||||
component: {
|
||||
disabled: userId != null,
|
||||
crossPage: true,
|
||||
multiple: false,
|
||||
select: {
|
||||
placeholder: "请选择用户",
|
||||
},
|
||||
createCrudOptions: createCrudOptionsUser,
|
||||
},
|
||||
},
|
||||
},
|
||||
amountYuan: {
|
||||
title: "充值金额",
|
||||
type: "number",
|
||||
form: {
|
||||
col: { span: 24 },
|
||||
rules: [{ required: true, message: "请输入充值金额" }],
|
||||
component: {
|
||||
name: "a-input-number",
|
||||
vModel: "value",
|
||||
min: 0,
|
||||
precision: 2,
|
||||
addonAfter: "元",
|
||||
style: { width: "100%" },
|
||||
},
|
||||
},
|
||||
},
|
||||
remark: {
|
||||
title: "备注",
|
||||
type: "text",
|
||||
form: {
|
||||
col: { span: 24 },
|
||||
helper: "选填",
|
||||
},
|
||||
},
|
||||
},
|
||||
async onSubmit(form: any) {
|
||||
await api.Recharge({
|
||||
userId: form.userId,
|
||||
amount: util.amount.toCent(form.amountYuan || 0),
|
||||
remark: form.remark,
|
||||
});
|
||||
await crudExpose.doRefresh();
|
||||
notification.success({ message: "充值成功" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
crudOptions: {
|
||||
request: {
|
||||
pageRequest,
|
||||
},
|
||||
toolbar: { show: false },
|
||||
actionbar: {
|
||||
buttons: {
|
||||
add: { show: false },
|
||||
recharge: {
|
||||
text: "余额充值",
|
||||
type: "primary",
|
||||
click: () => openRecharge(),
|
||||
},
|
||||
log: {
|
||||
text: "余额变更记录",
|
||||
type: "primary",
|
||||
click: () => router.push("/sys/suite/wallet-log"),
|
||||
},
|
||||
},
|
||||
},
|
||||
rowHandle: {
|
||||
width: 220,
|
||||
fixed: "right",
|
||||
buttons: {
|
||||
view: { show: false },
|
||||
edit: { show: false },
|
||||
copy: { show: false },
|
||||
remove: { show: false },
|
||||
walletLog: {
|
||||
text: "余额变更记录",
|
||||
type: "link",
|
||||
click: ({ row }) => router.push({ path: "/sys/suite/wallet-log", query: { userId: row.userId } }),
|
||||
},
|
||||
recharge: {
|
||||
text: "充值",
|
||||
type: "link",
|
||||
click: ({ row }) => openRecharge(row.userId),
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
id: {
|
||||
title: "ID",
|
||||
type: "number",
|
||||
column: { width: 80 },
|
||||
form: { show: false },
|
||||
},
|
||||
userId: {
|
||||
title: "用户",
|
||||
type: "table-select",
|
||||
dict: userDict,
|
||||
search: { show: true, col: { span: 3 } },
|
||||
column: { width: 160 },
|
||||
form: {
|
||||
show: false,
|
||||
component: {
|
||||
crossPage: true,
|
||||
multiple: false,
|
||||
select: {
|
||||
placeholder: "点击选择用户",
|
||||
},
|
||||
createCrudOptions: createCrudOptionsUser,
|
||||
},
|
||||
},
|
||||
},
|
||||
availableAmount: moneyColumn("可用余额"),
|
||||
totalAmount: moneyColumn("总余额"),
|
||||
frozenAmount: moneyColumn("冻结余额"),
|
||||
totalIncomeAmount: moneyColumn("累计收入"),
|
||||
totalConsumedAmount: moneyColumn("累计消费"),
|
||||
totalWithdrawAmount: moneyColumn("累计提现"),
|
||||
createTime: {
|
||||
title: "创建时间",
|
||||
type: "datetime",
|
||||
form: { show: false },
|
||||
column: { sorter: true, width: 160 },
|
||||
},
|
||||
updateTime: {
|
||||
title: "更新时间",
|
||||
type: "datetime",
|
||||
form: { show: false },
|
||||
column: { width: 160 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<fs-page>
|
||||
<template #header>
|
||||
<div class="title">
|
||||
余额管理
|
||||
<span class="sub">管理用户余额,可给用户充值</span>
|
||||
</div>
|
||||
</template>
|
||||
<fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud>
|
||||
</fs-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useFs } from "@fast-crud/fast-crud";
|
||||
import createCrudOptions from "./crud";
|
||||
import { useMounted } from "/@/use/use-mounted";
|
||||
|
||||
defineOptions({
|
||||
name: "SysWallet",
|
||||
});
|
||||
|
||||
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
|
||||
|
||||
// 页面打开后获取列表数据
|
||||
useMounted(async () => {
|
||||
await crudExpose.doRefresh();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
|
||||
import { useRoute } from "vue-router";
|
||||
import { util } from "/@/utils";
|
||||
import createCrudOptionsUser from "/@/views/sys/authority/user/crud";
|
||||
import * as api from "../api";
|
||||
|
||||
function moneyText(amount: number) {
|
||||
const yuan = util.amount.toYuan(Math.abs(amount || 0));
|
||||
if (amount < 0) {
|
||||
return `-¥${yuan}`;
|
||||
}
|
||||
return `¥${yuan}`;
|
||||
}
|
||||
|
||||
export default function (): CreateCrudOptionsRet {
|
||||
const route = useRoute();
|
||||
const userIdFromQuery = route.query.userId ? Number(route.query.userId) : undefined;
|
||||
|
||||
const userDict = dict({
|
||||
async getNodesByValues(ids: number[]) {
|
||||
return await api.GetSimpleUserByIds(ids);
|
||||
},
|
||||
value: "id",
|
||||
label: "nickName",
|
||||
});
|
||||
|
||||
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
|
||||
return await api.GetWalletLogs(query);
|
||||
};
|
||||
|
||||
return {
|
||||
crudOptions: {
|
||||
request: { pageRequest },
|
||||
search: {
|
||||
initialForm: { userId: userIdFromQuery },
|
||||
},
|
||||
actionbar: { show: false },
|
||||
toolbar: { show: false },
|
||||
rowHandle: { show: false },
|
||||
columns: {
|
||||
createTime: { title: "时间", type: "datetime", column: { width: 180 } },
|
||||
userId: {
|
||||
title: "用户",
|
||||
type: "table-select",
|
||||
dict: userDict,
|
||||
search: { show: true, col: { span: 3 } },
|
||||
column: { width: 160 },
|
||||
form: {
|
||||
show: false,
|
||||
component: {
|
||||
crossPage: true,
|
||||
multiple: false,
|
||||
select: { placeholder: "点击选择用户" },
|
||||
createCrudOptions: createCrudOptionsUser,
|
||||
},
|
||||
},
|
||||
},
|
||||
type: {
|
||||
title: "类型",
|
||||
type: "dict-select",
|
||||
dict: dict({
|
||||
data: [
|
||||
{ label: "收益入账", value: "income", color: "success" },
|
||||
{ label: "余额抵扣", value: "consume", color: "default" },
|
||||
{ label: "提现扣减", value: "withdraw", color: "warning" },
|
||||
],
|
||||
}),
|
||||
search: { show: true, col: { span: 3 } },
|
||||
column: { width: 120 },
|
||||
},
|
||||
amount: {
|
||||
title: "变动金额",
|
||||
type: "number",
|
||||
column: {
|
||||
width: 120,
|
||||
cellRender({ value }) {
|
||||
const amount = Number(value || 0);
|
||||
return <span class={amount < 0 ? "text-green-500" : "text-red-500"}>{moneyText(amount)}</span>;
|
||||
},
|
||||
},
|
||||
},
|
||||
balanceAfter: {
|
||||
title: "变动后余额",
|
||||
type: "number",
|
||||
column: {
|
||||
width: 130,
|
||||
cellRender({ value }) {
|
||||
return <span class="text-red-500">{moneyText(Number(value || 0))}</span>;
|
||||
},
|
||||
},
|
||||
},
|
||||
remark: {
|
||||
title: "备注",
|
||||
type: "text",
|
||||
column: { minWidth: 220 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<fs-page>
|
||||
<template #header>
|
||||
<div class="title">
|
||||
{{ pageTitle }}
|
||||
<span class="sub">{{ pageSub }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<fs-crud ref="crudRef" v-bind="crudBinding"> </fs-crud>
|
||||
</fs-page>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useFs } from "@fast-crud/fast-crud";
|
||||
import createCrudOptions from "./crud";
|
||||
import { useMounted } from "/@/use/use-mounted";
|
||||
|
||||
defineOptions({
|
||||
name: "SysWalletLog",
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const isUserLog = computed(() => route.query.userId != null);
|
||||
const pageTitle = computed(() => {
|
||||
if (isUserLog.value) {
|
||||
return "用户余额变更记录";
|
||||
}
|
||||
return "余额变更记录";
|
||||
});
|
||||
const pageSub = computed(() => {
|
||||
if (isUserLog.value) {
|
||||
return "查看该用户的余额变更明细";
|
||||
}
|
||||
return "查看所有用户的余额变更明细";
|
||||
});
|
||||
|
||||
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
|
||||
|
||||
// 页面打开后获取列表数据
|
||||
useMounted(async () => {
|
||||
await crudExpose.doRefresh();
|
||||
});
|
||||
</script>
|
||||
@@ -3,6 +3,24 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.44.4](https://github.com/certd/certd/compare/v1.44.3...v1.44.4) (2026-09-12)
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* 新增余额管理,管理员充值功能 ([bded368](https://github.com/certd/certd/commit/bded368cbb8974b6937ae260e448c25fae5c33ad))
|
||||
* **acme-account-access:** AcmeAccount授权允许修改eab,重新生成account ([6a649aa](https://github.com/certd/certd/commit/6a649aaca083ccd3b517f7d676a58220aa33b37d))
|
||||
|
||||
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
|
||||
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
|
||||
|
||||
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -43,7 +43,7 @@ input:
|
||||
|
||||
return {
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ input:
|
||||
return form.access?._needEAB === true;
|
||||
}),
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ input:
|
||||
return form.access?._needEAB === true;
|
||||
}),
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ input:
|
||||
placeholder: api token,用户 API 令牌
|
||||
helper: |-
|
||||
前往 [获取API令牌](https://dash.cloudflare.com/profile/api-tokens),注意是令牌,不是密钥。
|
||||
token权限必须包含:[Zone区域-Zone区域-Edit编辑], [Zone区域-DNS-Edit编辑]
|
||||
token权限必须包含两条:
|
||||
[Zone区域-Zone区域-Edit编辑]
|
||||
[Zone区域-DNS-Edit编辑]
|
||||
required: true
|
||||
encrypt: true
|
||||
proxy:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: fastly
|
||||
title: Fastly授权
|
||||
icon: simple-icons:fastly
|
||||
desc: Fastly CDN / TLS Custom Certificates API 授权
|
||||
input:
|
||||
apiKey:
|
||||
title: API Token
|
||||
component:
|
||||
placeholder: Fastly API Key / Token
|
||||
helper: 前往 Fastly Account Settings -> Personal Access Tokens 创建 Token
|
||||
required: true
|
||||
encrypt: true
|
||||
proxy:
|
||||
title: HTTP代理
|
||||
component:
|
||||
placeholder: http://xxxx.xxx.xx:10811
|
||||
helper: 可选:是否使用 HTTP 代理访问 Fastly API
|
||||
required: false
|
||||
encrypt: false
|
||||
testRequest:
|
||||
title: 测试
|
||||
component:
|
||||
name: api-test
|
||||
action: TestRequest
|
||||
helper: 测试授权是否正确
|
||||
pluginType: access
|
||||
type: builtIn
|
||||
scriptFilePath: /plugins/plugin-fastly/access.js
|
||||
@@ -150,6 +150,15 @@ input:
|
||||
|
||||
helper: 请选择distributions id
|
||||
order: 0
|
||||
waitForDeployed:
|
||||
title: 等待部署完成
|
||||
value: false
|
||||
helper: >-
|
||||
开启后,会轮询等待每个CloudFront分配状态变为Deployed(通常需要几分钟)再结束任务。关闭时,提交证书更新后立即结束——CloudFront会在后台自行完成部署。仅当后续任务依赖证书已全球生效时才需要开启。
|
||||
component:
|
||||
name: a-switch
|
||||
vModel: checked
|
||||
order: 0
|
||||
output: {}
|
||||
pluginType: deploy
|
||||
type: builtIn
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
showRunStrategy: false
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 1
|
||||
name: FastlyDeployCert
|
||||
title: Fastly-部署TLS激活
|
||||
desc: 部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名)
|
||||
icon: simple-icons:fastly
|
||||
group: cdn
|
||||
input:
|
||||
cert:
|
||||
title: 域名证书
|
||||
helper: 选择前置任务输出的域名证书(将自动上传到 Fastly),或选择前置【Fastly-上传证书到Fastly】任务输出的 Fastly 证书ID。
|
||||
component:
|
||||
name: output-selector
|
||||
from:
|
||||
- ':cert:'
|
||||
- FastlyUploadCert
|
||||
required: true
|
||||
order: 0
|
||||
name:
|
||||
title: 证书名称
|
||||
helper: 可选。当传入的是域名证书需要先上传时,作为 Fastly 上的自定义证书名称/标签
|
||||
component:
|
||||
placeholder: '例如: my-fastly-cert'
|
||||
required: false
|
||||
order: 0
|
||||
accessId:
|
||||
title: Access授权
|
||||
helper: Fastly 授权凭证
|
||||
component:
|
||||
name: access-selector
|
||||
type: fastly
|
||||
required: true
|
||||
order: 0
|
||||
domainIds:
|
||||
title: TLS 域名
|
||||
component:
|
||||
name: remote-select
|
||||
vModel: value
|
||||
mode: tags
|
||||
type: plugin
|
||||
action: onGetTlsDomainList
|
||||
search: false
|
||||
pager: false
|
||||
single: false
|
||||
watches:
|
||||
- certDomains
|
||||
- accessId
|
||||
required: true
|
||||
mergeScript: |2-
|
||||
|
||||
return {
|
||||
component:{
|
||||
form: ctx.compute(({form})=>{
|
||||
return form
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
helper: >-
|
||||
选择要绑定证书的域名,可多选(将为每个域名创建或更新一个 TLS 激活)。列表为你 Fastly 服务上配置的域名,请选择被证书覆盖的具体主机名(如
|
||||
www.example.com),而非通配 *.example.com(除非确有服务使用通配域名)。列表没有的也可直接输入。
|
||||
order: 0
|
||||
configurationId:
|
||||
title: TLS 配置
|
||||
component:
|
||||
name: remote-select
|
||||
vModel: value
|
||||
mode: tags
|
||||
type: plugin
|
||||
action: onGetTlsConfigurationList
|
||||
search: false
|
||||
pager: false
|
||||
single: true
|
||||
watches:
|
||||
- certDomains
|
||||
- accessId
|
||||
required: true
|
||||
mergeScript: |2-
|
||||
|
||||
return {
|
||||
component:{
|
||||
form: ctx.compute(({form})=>{
|
||||
return form
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
helper: 选择关联的 TLS 配置
|
||||
order: 0
|
||||
output: {}
|
||||
pluginType: deploy
|
||||
type: builtIn
|
||||
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-deploy-to-service.js
|
||||
@@ -0,0 +1,49 @@
|
||||
showRunStrategy: false
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 1
|
||||
name: FastlyPurgeCache
|
||||
title: Fastly-清除缓存
|
||||
desc: 清除 Fastly 指定 Service 的所有缓存 (Purge All)
|
||||
icon: simple-icons:fastly
|
||||
group: cdn
|
||||
input:
|
||||
accessId:
|
||||
title: Access授权
|
||||
helper: Fastly 授权凭证
|
||||
component:
|
||||
name: access-selector
|
||||
type: fastly
|
||||
required: true
|
||||
order: 0
|
||||
serviceId:
|
||||
title: Service (服务)
|
||||
component:
|
||||
name: remote-select
|
||||
vModel: value
|
||||
mode: tags
|
||||
type: plugin
|
||||
action: onGetServiceList
|
||||
search: false
|
||||
pager: false
|
||||
single: false
|
||||
watches:
|
||||
- certDomains
|
||||
- accessId
|
||||
required: true
|
||||
mergeScript: |2-
|
||||
|
||||
return {
|
||||
component:{
|
||||
form: ctx.compute(({form})=>{
|
||||
return form
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
helper: 选择要清理所有缓存的 Fastly 服务
|
||||
order: 0
|
||||
output: {}
|
||||
pluginType: deploy
|
||||
type: builtIn
|
||||
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-purge-cache.js
|
||||
@@ -0,0 +1,60 @@
|
||||
showRunStrategy: false
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 1
|
||||
name: FastlyRefreshCert
|
||||
title: Fastly-更新证书
|
||||
desc: 自动更新 Fastly CDN 上的已有自定义证书
|
||||
icon: simple-icons:fastly
|
||||
group: cdn
|
||||
input:
|
||||
cert:
|
||||
title: 域名证书
|
||||
helper: 请选择前置任务输出的域名证书
|
||||
component:
|
||||
name: output-selector
|
||||
from:
|
||||
- ':cert:'
|
||||
required: true
|
||||
order: 0
|
||||
accessId:
|
||||
title: Access授权
|
||||
helper: Fastly 授权凭证
|
||||
component:
|
||||
name: access-selector
|
||||
type: fastly
|
||||
required: true
|
||||
order: 0
|
||||
certList:
|
||||
title: 证书列表
|
||||
component:
|
||||
name: remote-select
|
||||
vModel: value
|
||||
mode: tags
|
||||
type: plugin
|
||||
action: onGetCertList
|
||||
search: false
|
||||
pager: false
|
||||
single: false
|
||||
watches:
|
||||
- certDomains
|
||||
- accessId
|
||||
required: true
|
||||
mergeScript: |2-
|
||||
|
||||
return {
|
||||
component:{
|
||||
form: ctx.compute(({form})=>{
|
||||
return form
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
helper: >-
|
||||
选择要更新的 Fastly 证书 (必须是已存在的自定义证书)。注意:Fastly
|
||||
要求更新后的证书与原证书使用相同私钥,请确保续期时复用私钥(reuse key),否则更新会失败。
|
||||
order: 0
|
||||
output: {}
|
||||
pluginType: deploy
|
||||
type: builtIn
|
||||
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-refresh-cert.js
|
||||
@@ -0,0 +1,49 @@
|
||||
showRunStrategy: false
|
||||
default:
|
||||
strategy:
|
||||
runStrategy: 1
|
||||
name: FastlyUploadCert
|
||||
title: Fastly-上传证书到Fastly
|
||||
desc: 部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN
|
||||
icon: simple-icons:fastly
|
||||
group: cdn
|
||||
input:
|
||||
cert:
|
||||
title: 域名证书
|
||||
helper: 请选择前置任务输出的域名证书
|
||||
component:
|
||||
name: output-selector
|
||||
from:
|
||||
- ':cert:'
|
||||
required: true
|
||||
order: 0
|
||||
accessId:
|
||||
title: Access授权
|
||||
helper: Fastly 授权凭证
|
||||
component:
|
||||
name: access-selector
|
||||
type: fastly
|
||||
required: true
|
||||
order: 0
|
||||
certificateId:
|
||||
title: 证书ID (tls_certificate_id)
|
||||
helper: >-
|
||||
可选。若填写则对 Fastly 已有证书进行更新(PATCH);留空则新建上传(POST)。注意:Fastly
|
||||
更新证书要求新证书与原证书使用相同的私钥,请确保续期时复用私钥(reuse key),否则更新会失败。
|
||||
component:
|
||||
placeholder: '例如: tls_cert_xxx (留空表示新建)'
|
||||
required: false
|
||||
order: 0
|
||||
name:
|
||||
title: 证书名称
|
||||
helper: 可选。上传到 Fastly 上的自定义证书名称/标签
|
||||
component:
|
||||
placeholder: '例如: my-fastly-cert'
|
||||
required: false
|
||||
order: 0
|
||||
output:
|
||||
fastlyCertId:
|
||||
title: Fastly 证书 ID
|
||||
pluginType: deploy
|
||||
type: builtIn
|
||||
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-upload-cert.js
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@certd/ui-server",
|
||||
"version": "1.44.2",
|
||||
"version": "1.44.4",
|
||||
"description": "fast-server base midway",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -43,20 +43,20 @@
|
||||
"lint1": "eslint --fix --ext .ts src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@certd/acme-client": "^1.44.2",
|
||||
"@certd/basic": "^1.44.2",
|
||||
"@certd/commercial-core": "^1.44.2",
|
||||
"@certd/acme-client": "^1.44.4",
|
||||
"@certd/basic": "^1.44.4",
|
||||
"@certd/commercial-core": "^1.44.4",
|
||||
"@certd/cv4pve-api-javascript": "^8.4.2",
|
||||
"@certd/jdcloud": "^1.44.2",
|
||||
"@certd/lib-huawei": "^1.44.2",
|
||||
"@certd/lib-k8s": "^1.44.2",
|
||||
"@certd/lib-server": "^1.44.2",
|
||||
"@certd/midway-flyway-js": "^1.44.2",
|
||||
"@certd/pipeline": "^1.44.2",
|
||||
"@certd/plugin-cert": "^1.44.2",
|
||||
"@certd/plugin-lib": "^1.44.2",
|
||||
"@certd/plugin-plus": "^1.44.2",
|
||||
"@certd/plus-core": "^1.44.2",
|
||||
"@certd/jdcloud": "^1.44.4",
|
||||
"@certd/lib-huawei": "^1.44.4",
|
||||
"@certd/lib-k8s": "^1.44.4",
|
||||
"@certd/lib-server": "^1.44.4",
|
||||
"@certd/midway-flyway-js": "^1.44.4",
|
||||
"@certd/pipeline": "^1.44.4",
|
||||
"@certd/plugin-cert": "^1.44.4",
|
||||
"@certd/plugin-lib": "^1.44.4",
|
||||
"@certd/plugin-plus": "^1.44.4",
|
||||
"@certd/plus-core": "^1.44.4",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"@midwayjs/bootstrap": "3.20.11",
|
||||
"@midwayjs/cache": "3.14.0",
|
||||
|
||||
@@ -45,6 +45,7 @@ process.on("uncaughtException", error => {
|
||||
// setInterval(log, 200);
|
||||
// log()
|
||||
// }
|
||||
|
||||
// startHeapLog();
|
||||
|
||||
@Configuration({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getRuntimeDepsService, initRuntimeDepsService } from "@certd/pipeline";
|
||||
import { pluginRegistry, accessRegistry, notificationRegistry } from "@certd/pipeline";
|
||||
import { dnsProviderRegistry } from "@certd/plugin-lib";
|
||||
import { addonRegistry } from "@certd/lib-server";
|
||||
import { PluginConfigService } from "../plugin/service/plugin-config-service.js";
|
||||
|
||||
@Provide()
|
||||
@Scope(ScopeEnum.Request, { allowDowngrade: true })
|
||||
@@ -13,6 +14,9 @@ export class AutoLoadPlugins {
|
||||
@Inject()
|
||||
pluginService: PluginService;
|
||||
|
||||
@Inject()
|
||||
pluginConfigService: PluginConfigService;
|
||||
|
||||
@Config("runtimeDeps")
|
||||
runtimeDepsConfig: any;
|
||||
|
||||
@@ -34,8 +38,8 @@ export class AutoLoadPlugins {
|
||||
}
|
||||
// await import("../../plugins/index.js")
|
||||
await this.pluginService.registerFromDb();
|
||||
|
||||
await registerPaymentProviders();
|
||||
await this.pluginConfigService.loadAllPluginSetting();
|
||||
registerPaymentProviders();
|
||||
logger.info(`加载插件完成,加载模式:${process.env.certd_plugin_loadmode}`);
|
||||
|
||||
//初始化第三方依赖服务
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
|
||||
import { PluginService } from "./plugin-service.js";
|
||||
import { accessRegistry, notificationRegistry, pluginRegistry } from "@certd/pipeline";
|
||||
import { dnsProviderRegistry } from "@certd/plugin-lib";
|
||||
import { addonRegistry } from "@certd/lib-server";
|
||||
|
||||
export type PluginConfig = {
|
||||
name: string;
|
||||
@@ -66,15 +69,54 @@ export class PluginConfigService {
|
||||
};
|
||||
const { id } = await this.pluginService.add(pluginEntity);
|
||||
pluginEntity.id = id;
|
||||
this.loadPluginSetting(name, sysSetting);
|
||||
} else {
|
||||
const setting = JSON.parse(pluginEntity.sysSetting || "{}");
|
||||
if (sysSetting.metadata) {
|
||||
setting.metadata = sysSetting.metadata;
|
||||
}
|
||||
if (sysSetting.input) {
|
||||
//如果没有新提交,不覆盖旧的input
|
||||
setting.input = sysSetting.input;
|
||||
}
|
||||
await this.pluginService.getRepository().update({ fullName: name }, { sysSetting: JSON.stringify(setting) });
|
||||
this.loadPluginSetting(name, setting);
|
||||
}
|
||||
}
|
||||
|
||||
async loadPluginSetting(name: string, sysSetting: any) {
|
||||
let pluginDefine = null;
|
||||
if (!pluginDefine) {
|
||||
pluginDefine = accessRegistry.getDefine(name);
|
||||
}
|
||||
if (!pluginDefine) {
|
||||
pluginDefine = pluginRegistry.getDefine(name);
|
||||
}
|
||||
if (!pluginDefine) {
|
||||
pluginDefine = dnsProviderRegistry.getDefine(name);
|
||||
}
|
||||
if (!pluginDefine) {
|
||||
pluginDefine = addonRegistry.getDefine(name);
|
||||
}
|
||||
if (!pluginDefine) {
|
||||
pluginDefine = notificationRegistry.getDefine(name);
|
||||
}
|
||||
if (!pluginDefine) {
|
||||
return;
|
||||
}
|
||||
pluginDefine.sysSetting = sysSetting;
|
||||
}
|
||||
|
||||
async loadAllPluginSetting() {
|
||||
const pluginSettings = await this.pluginService.getRepository().find({
|
||||
select: {
|
||||
fullName: true,
|
||||
type: true,
|
||||
sysSetting: true,
|
||||
},
|
||||
});
|
||||
for (const plugin of pluginSettings) {
|
||||
this.loadPluginSetting(plugin.fullName, JSON.parse(plugin.sysSetting || "{}"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -659,26 +659,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
return this.builtInPluginService.getByType(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单可能直接提交对象形式的 dependPlugins(KvInput),落库前统一序列化为 YAML 字符串。
|
||||
*/
|
||||
// private normalizeDependPluginsParam(param: any) {
|
||||
// if (param.dependPlugins && typeof param.dependPlugins === "object" && !Array.isArray(param.dependPlugins)) {
|
||||
// param.dependPlugins = toDependPluginsYaml(param.dependPlugins);
|
||||
// }
|
||||
// }
|
||||
|
||||
// private normalizePluginYamlParam(param: any, field: "extra" | "dependPlugins") {
|
||||
// const value = param[field];
|
||||
// if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
// param[field] = yaml.dump(value, {
|
||||
// lineWidth: -1,
|
||||
// noRefs: true,
|
||||
// sortKeys: false,
|
||||
// });
|
||||
// }
|
||||
// }
|
||||
|
||||
private normalizeStorePluginAuthor(plugin: Record<string, any>) {
|
||||
const author = `${plugin.author || ""}`.trim();
|
||||
plugin.author = author;
|
||||
@@ -693,7 +673,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
* @param param 数据
|
||||
*/
|
||||
async add(param: any) {
|
||||
// this.normalizeDependPluginsParam(param);
|
||||
param.type = normalizePluginSourceType(param.type);
|
||||
if (param.type === "store") {
|
||||
this.normalizeStorePluginAuthor(param);
|
||||
@@ -849,7 +828,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
private attachOnlineInstallState(list: PluginEntity[], bindUserId?: number) {
|
||||
return list.map(item => {
|
||||
const record = this.toOnlinePluginBean(item);
|
||||
record.fullName = this.getOnlinePluginFullName(record);
|
||||
record.upgradeAvailable = record.installed ? isOnlinePluginUpgradeAvailable(record.version, record.latest) : false;
|
||||
record.editable = isAuthor(record, bindUserId);
|
||||
return record;
|
||||
@@ -903,6 +881,7 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
} else {
|
||||
storePlugin = await this.findOne({ where: { ...query, name: dependencyRef } });
|
||||
}
|
||||
|
||||
if (storePlugin) {
|
||||
return {
|
||||
plugin: storePlugin,
|
||||
@@ -1004,6 +983,13 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
score: itemFromStore.score,
|
||||
downloadCount: itemFromStore.downloadCount,
|
||||
latestVersion: itemFromStore.version,
|
||||
developerId: itemFromStore.developerId,
|
||||
vip: itemFromStore.vip,
|
||||
dependPlugins: itemFromStore.dependPlugins,
|
||||
aiCheckStatus: itemFromStore.aiCheckStatus,
|
||||
status: itemFromStore.status,
|
||||
appId: itemFromStore.appId,
|
||||
author: itemFromStore.author,
|
||||
};
|
||||
await this.updateById(updateBean);
|
||||
} else {
|
||||
@@ -1077,7 +1063,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
override: true,
|
||||
type: "store",
|
||||
});
|
||||
// await this.refreshOnlinePluginDownloadCount(res.fullName || fullName, res.plugin?.downloadCount);
|
||||
return {
|
||||
...importRes,
|
||||
fullName: res.fullName || fullName,
|
||||
@@ -1288,21 +1273,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
await this.sysSettingsService.saveSetting(setting);
|
||||
}
|
||||
|
||||
// private async refreshOnlinePluginDownloadCount(fullName: string, downloadCount?: number) {
|
||||
// if (!fullName || downloadCount == null) {
|
||||
// return;
|
||||
// }
|
||||
// await this.updateWhere(
|
||||
// {
|
||||
// type: "store",
|
||||
// fullName,
|
||||
// },
|
||||
// {
|
||||
// downloadCount,
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
async unRegisterById(id: any) {
|
||||
const item = await this.info(id);
|
||||
if (!item) {
|
||||
@@ -1347,19 +1317,11 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
if (!param?.id) {
|
||||
throw new Error("id 不能为空");
|
||||
}
|
||||
// this.normalizePluginYamlParam(param, "extra");
|
||||
// this.normalizeDependPluginsParam(param);
|
||||
await this.unRegisterById(param.id);
|
||||
const res = await super.update(param);
|
||||
await this.registerById(param.id);
|
||||
return res;
|
||||
}
|
||||
async compile(code: string) {
|
||||
const ts = await import("typescript");
|
||||
return ts.transpileModule(code, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext },
|
||||
}).outputText;
|
||||
}
|
||||
|
||||
async importer(modulePath: string) {
|
||||
if (!modulePath) {
|
||||
@@ -1399,7 +1361,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
const plugin = info[0];
|
||||
try {
|
||||
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
|
||||
// const script = await this.compile(plugin.content);
|
||||
const script = plugin.content;
|
||||
const getPluginClass = new AsyncFunction("_ctx", script);
|
||||
const importer = this.importer.bind(this);
|
||||
@@ -1644,26 +1605,6 @@ export class PluginService extends BaseService<PluginEntity> {
|
||||
);
|
||||
}
|
||||
|
||||
isNewVersion(version: string, latestVersion: string) {
|
||||
if (!latestVersion) {
|
||||
return false;
|
||||
}
|
||||
if (latestVersion === version) {
|
||||
return false;
|
||||
}
|
||||
//分段比较
|
||||
const current = version.split(".");
|
||||
const latest = latestVersion.split(".");
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
if (parseInt(latest[i]) > parseInt(current[i])) {
|
||||
return true;
|
||||
} else if (parseInt(latest[i]) < parseInt(current[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async getPluginDefine(name: string) {
|
||||
return pluginRegistry.getDefine(name);
|
||||
}
|
||||
|
||||
@@ -50,3 +50,4 @@
|
||||
// export * from './plugin-nginx-proxy-manager/index.js'
|
||||
// export * from './plugin-hipmdnsmgr/index.js'
|
||||
// export * from './plugin-dynadot/index.js'
|
||||
// export * from './plugin-fastly/index.js';
|
||||
|
||||
@@ -35,7 +35,7 @@ async function createAwsClient(records: any[]) {
|
||||
ChangeResourceRecordSetsCommand,
|
||||
}),
|
||||
} as any,
|
||||
logger: { info() {}, error() {} } as any,
|
||||
logger: { info() {}, warn() {}, error() {} } as any,
|
||||
region: "us-east-1",
|
||||
});
|
||||
return { client, sentChanges };
|
||||
@@ -62,10 +62,7 @@ describe("AwsClient.route53ChangeRecord", () => {
|
||||
|
||||
const change = sentChanges[0].ChangeBatch.Changes[0];
|
||||
assert.equal(change.Action, "UPSERT");
|
||||
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [
|
||||
{ Value: '"apex-token"' },
|
||||
{ Value: '"wildcard-token"' },
|
||||
]);
|
||||
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [{ Value: '"apex-token"' }, { Value: '"wildcard-token"' }]);
|
||||
assert.equal(change.ResourceRecordSet.TTL, 300);
|
||||
});
|
||||
|
||||
@@ -92,3 +89,124 @@ describe("AwsClient.route53ChangeRecord", () => {
|
||||
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [{ Value: '"wildcard-token"' }]);
|
||||
});
|
||||
});
|
||||
|
||||
function throttle(name: string, extra: Record<string, any> = {}) {
|
||||
const err: any = new Error(name);
|
||||
err.name = name;
|
||||
return Object.assign(err, extra);
|
||||
}
|
||||
|
||||
describe("AwsClient.withRetry", () => {
|
||||
it("retries a throttled call and then resolves", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
const result = await client.withRetry(async () => {
|
||||
calls++;
|
||||
if (calls < 3) {
|
||||
throw throttle("ThrottlingException");
|
||||
}
|
||||
return "ok";
|
||||
});
|
||||
assert.equal(result, "ok");
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
it("retries Route53 PriorRequestNotComplete", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
const result = await client.withRetry(async () => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
throw throttle("PriorRequestNotComplete");
|
||||
}
|
||||
return "done";
|
||||
});
|
||||
assert.equal(result, "done");
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("retries on HTTP 429 without a known error code", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
await client.withRetry(async () => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
const err: any = new Error("too many requests");
|
||||
err.$metadata = { httpStatusCode: 429 };
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("retries transient HTTP 5xx", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
await client.withRetry(async () => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
const err: any = new Error("service unavailable");
|
||||
err.$metadata = { httpStatusCode: 503 };
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("retries when the SDK marks the error $retryable", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
await client.withRetry(async () => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
const err: any = new Error("internal failure");
|
||||
err.name = "InternalFailure";
|
||||
err.$retryable = {};
|
||||
throw err;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("retries connection-level errors", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
await client.withRetry(async () => {
|
||||
calls++;
|
||||
if (calls < 2) {
|
||||
throw throttle("Error", { code: "ECONNRESET" });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("does not retry non-throttling errors", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
client.withRetry(async () => {
|
||||
calls++;
|
||||
throw throttle("AccessDeniedException");
|
||||
}),
|
||||
/AccessDeniedException/
|
||||
);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
it("stops after maxAttempts and rethrows the throttling error", async () => {
|
||||
const { client } = await createAwsClient([]);
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
client.withRetry(async () => {
|
||||
calls++;
|
||||
throw throttle("Throttling", { message: "Rate exceeded" });
|
||||
}, 3),
|
||||
/Throttling|Rate exceeded/
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export class AwsClient {
|
||||
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
|
||||
secretAccessKey: this.access.secretAccessKey,
|
||||
},
|
||||
// Disable the SDK's own retries; withRetry/doRequest is the single retry authority.
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
// Split the full PEM chain: first block is the leaf cert, the rest is the intermediate chain
|
||||
@@ -57,6 +59,7 @@ export class AwsClient {
|
||||
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
|
||||
secretAccessKey: this.access.secretAccessKey,
|
||||
},
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
const command = new GetCallerIdentityCommand({});
|
||||
@@ -75,6 +78,7 @@ export class AwsClient {
|
||||
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
|
||||
secretAccessKey: this.access.secretAccessKey,
|
||||
},
|
||||
maxAttempts: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,10 +206,107 @@ export class AwsClient {
|
||||
|
||||
async doRequest<T>(call: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await call();
|
||||
} catch (err) {
|
||||
this.logger.error(`调用接口失败:${err.Error?.Message || err.message},requestId:${err.requestId}`);
|
||||
return await this.withRetry(call);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`请求失败:${err.Error?.Message || err.message},requestId:${err.requestId}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries an AWS SDK call with exponential backoff + jitter while the error is
|
||||
* retryable. See {@link AwsClient.isRetryableAwsError}.
|
||||
*
|
||||
* `T` defaults to `any` so that call sites passing an untyped SDK client
|
||||
* (`@aws-sdk/*` is a lazyDependency and has no types at build time) don't
|
||||
* collapse the result to `unknown`.
|
||||
*/
|
||||
async withRetry<T = any>(call: () => Promise<T>, maxAttempts = 5, baseDelayMs = 2000): Promise<T> {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await call();
|
||||
} catch (err: any) {
|
||||
if (attempt >= maxAttempts || !AwsClient.isRetryableAwsError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const code = err?.name || err?.Code || err?.code || err?.$metadata?.httpStatusCode || "unknown";
|
||||
const ceiling = baseDelayMs * Math.pow(2, attempt - 1); // 2s, 4s, 8s, 16s …
|
||||
const delay = Math.round(ceiling / 2 + Math.random() * (ceiling / 2)); // equal jitter: ~1-2s, ~2-4s, ~4-8s, ~8-16s
|
||||
this.logger.warn(`AWS request failed (${code}), attempt ${attempt}/${maxAttempts}, retrying in ${delay}ms…`);
|
||||
await utils.sleep(delay);
|
||||
}
|
||||
}
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects AWS errors that are safe to retry. Because we disable the SDK's own
|
||||
* retries (maxAttempts: 1 on every client), this is the single source of truth
|
||||
* and covers what the SDK's `standard` strategy would have retried:
|
||||
* - throttling / rate-limit codes across ACM / STS / CloudFront / Route53,
|
||||
* including Route53's PriorRequestNotComplete concurrent-change limit;
|
||||
* - SDK v3 retry hints (`$retryable`);
|
||||
* - transient HTTP 429 and 5xx;
|
||||
* - connection-level errors (ECONNRESET, ETIMEDOUT, EAI_AGAIN, …).
|
||||
*/
|
||||
static isRetryableAwsError(err: any): boolean {
|
||||
if (!err) {
|
||||
return false;
|
||||
}
|
||||
const retryableCodes = new Set([
|
||||
"TooManyRequestsException",
|
||||
"ThrottlingException",
|
||||
"ThrottledException",
|
||||
"RequestLimitExceeded",
|
||||
"RequestThrottled",
|
||||
"Throttling",
|
||||
"SlowDown",
|
||||
"PriorRequestNotComplete",
|
||||
"RequestTimeout",
|
||||
"RequestTimeoutException",
|
||||
"TimeoutError",
|
||||
]);
|
||||
const connectionCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH"]);
|
||||
const code = err.name || err.Code || err.code || "";
|
||||
const httpStatus = err.$metadata?.httpStatusCode;
|
||||
return (
|
||||
retryableCodes.has(code) ||
|
||||
connectionCodes.has(err.code) ||
|
||||
err.$retryable != null ||
|
||||
httpStatus === 429 ||
|
||||
(typeof httpStatus === "number" && httpStatus >= 500) ||
|
||||
(typeof err.message === "string" && err.message.toLowerCase().includes("rate exceeded"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls a CloudFront distribution until its Status becomes "Deployed".
|
||||
* CloudFront propagates changes globally and can take several minutes.
|
||||
*/
|
||||
async waitForDistributionDeployed(cloudFrontClient: any, distributionId: string, timeoutMs = 600_000, pollIntervalMs = 15_000): Promise<void> {
|
||||
const { GetDistributionCommand } = await this.access.importRuntime("@aws-sdk/client-cloudfront");
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
let status: string | undefined;
|
||||
try {
|
||||
const res: any = await this.withRetry(() => cloudFrontClient.send(new GetDistributionCommand({ Id: distributionId })));
|
||||
status = res?.Distribution?.Status;
|
||||
} catch (err: any) {
|
||||
// A retryable error that outlives withRetry's budget shouldn't abort the whole deploy;
|
||||
// keep polling until the deadline and let genuine (non-retryable) errors propagate.
|
||||
if (!AwsClient.isRetryableAwsError(err)) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.warn(`CloudFront status check failed (${err?.name || err?.code}), will retry on next poll…`);
|
||||
await utils.sleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`CloudFront distribution ${distributionId} status: ${status}`);
|
||||
if (status === "Deployed") {
|
||||
return;
|
||||
}
|
||||
await utils.sleep(pollIntervalMs);
|
||||
}
|
||||
throw new Error(`Timed out waiting for CloudFront distribution ${distributionId} to reach Deployed status`);
|
||||
}
|
||||
}
|
||||
|
||||
+50
-27
@@ -67,15 +67,32 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
|
||||
)
|
||||
distributionIds!: string[];
|
||||
|
||||
@TaskInput({
|
||||
title: "等待部署完成",
|
||||
value: false,
|
||||
helper: "开启后,会轮询等待每个CloudFront分配状态变为Deployed(通常需要几分钟)再结束任务。" + "关闭时,提交证书更新后立即结束——CloudFront会在后台自行完成部署。" + "仅当后续任务依赖证书已全球生效时才需要开启。",
|
||||
component: {
|
||||
name: "a-switch",
|
||||
vModel: "checked",
|
||||
},
|
||||
})
|
||||
waitForDeployed = false;
|
||||
|
||||
async onInstance() {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const access = await this.getAccess<AwsAccess>(this.accessId);
|
||||
|
||||
const acmClient = new AwsClient({
|
||||
access,
|
||||
region: this.region,
|
||||
logger: this.logger,
|
||||
});
|
||||
|
||||
let certId = this.cert as string;
|
||||
if (typeof this.cert !== "string") {
|
||||
//先上传
|
||||
certId = await this.uploadToACM(access, this.cert);
|
||||
certId = await this.uploadToACM(acmClient, this.cert);
|
||||
}
|
||||
//部署到CloudFront
|
||||
|
||||
@@ -86,42 +103,47 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
|
||||
accessKeyId: access.accessKeyId,
|
||||
secretAccessKey: access.secretAccessKey,
|
||||
},
|
||||
// Disable the SDK's own retries; acmClient.withRetry is the single retry authority.
|
||||
maxAttempts: 1,
|
||||
});
|
||||
|
||||
// update-distribution
|
||||
for (const distributionId of this.distributionIds) {
|
||||
// get-distribution-config
|
||||
const getDistributionConfigCommand = new GetDistributionConfigCommand({
|
||||
Id: distributionId,
|
||||
});
|
||||
// get-distribution-config (with retry for throttling)
|
||||
const configData: any = await acmClient.withRetry(() => cloudFrontClient.send(new GetDistributionConfigCommand({ Id: distributionId })));
|
||||
|
||||
const configData = await cloudFrontClient.send(getDistributionConfigCommand);
|
||||
await acmClient.withRetry(() =>
|
||||
cloudFrontClient.send(
|
||||
new UpdateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
...configData.DistributionConfig,
|
||||
ViewerCertificate: {
|
||||
...configData.DistributionConfig.ViewerCertificate,
|
||||
CloudFrontDefaultCertificate: false,
|
||||
ACMCertificateArn: certId,
|
||||
},
|
||||
},
|
||||
Id: distributionId,
|
||||
IfMatch: configData.ETag,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const updateDistributionCommand = new UpdateDistributionCommand({
|
||||
DistributionConfig: {
|
||||
...configData.DistributionConfig,
|
||||
ViewerCertificate: {
|
||||
...configData.DistributionConfig.ViewerCertificate,
|
||||
CloudFrontDefaultCertificate: false,
|
||||
ACMCertificateArn: certId,
|
||||
},
|
||||
},
|
||||
Id: distributionId,
|
||||
IfMatch: configData.ETag,
|
||||
});
|
||||
await cloudFrontClient.send(updateDistributionCommand);
|
||||
this.logger.info(`部署${distributionId}完成:`);
|
||||
if (this.waitForDeployed) {
|
||||
this.logger.info(`证书已提交到 ${distributionId},等待全局部署完成…`);
|
||||
// CloudFront propagates globally in a few minutes; only block when the
|
||||
// user opts in (e.g. a downstream task needs the cert already live).
|
||||
await acmClient.waitForDistributionDeployed(cloudFrontClient, distributionId);
|
||||
this.logger.info(`部署 ${distributionId} 完成`);
|
||||
} else {
|
||||
this.logger.info(`证书已提交到 ${distributionId},CloudFront 将在后台完成部署`);
|
||||
}
|
||||
}
|
||||
this.logger.info("部署完成");
|
||||
}
|
||||
|
||||
private async uploadToACM(access: AwsAccess, cert: CertInfo) {
|
||||
const acmClient = new AwsClient({
|
||||
access,
|
||||
region: this.region,
|
||||
logger: this.logger,
|
||||
});
|
||||
const awsCertARN = await acmClient.importCertificate(cert);
|
||||
private async uploadToACM(acmClient: AwsClient, cert: CertInfo) {
|
||||
const awsCertARN = await acmClient.withRetry(() => acmClient.importCertificate(cert));
|
||||
this.logger.info("证书上传成功,id=", awsCertARN);
|
||||
return awsCertARN;
|
||||
}
|
||||
@@ -140,6 +162,7 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
|
||||
accessKeyId: access.accessKeyId,
|
||||
secretAccessKey: access.secretAccessKey,
|
||||
},
|
||||
maxAttempts: 1,
|
||||
});
|
||||
// list-distributions
|
||||
const listDistributionsCommand = new ListDistributionsCommand({});
|
||||
|
||||
@@ -65,7 +65,7 @@ export class AwsUploadToACM extends AbstractTaskPlugin {
|
||||
region,
|
||||
logger: this.logger,
|
||||
});
|
||||
this.awsCertARN = await acmClient.importCertificate(cert);
|
||||
this.awsCertARN = await acmClient.withRetry(() => acmClient.importCertificate(cert));
|
||||
this.logger.info("证书上传成功,id=", this.awsCertARN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ export class AcmeAccountAccess extends BaseAccess {
|
||||
mergeScript: `
|
||||
return {
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -116,7 +116,7 @@ export class AcmeAccountAccess extends BaseAccess {
|
||||
return form.access?._needEAB === true;
|
||||
}),
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
|
||||
}
|
||||
}
|
||||
`,
|
||||
@@ -137,7 +137,7 @@ export class AcmeAccountAccess extends BaseAccess {
|
||||
return form.access?._needEAB === true;
|
||||
}),
|
||||
component: {
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account)
|
||||
disabled: ctx.compute(({form})=> !!form.access?.account && !form.access?.editAccount)
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -19,7 +19,7 @@ export class CloudflareAccess extends BaseAccess {
|
||||
component: {
|
||||
placeholder: "api token,用户 API 令牌",
|
||||
},
|
||||
helper: "前往 [获取API令牌](https://dash.cloudflare.com/profile/api-tokens),注意是令牌,不是密钥。\n token权限必须包含:[Zone区域-Zone区域-Edit编辑], [Zone区域-DNS-Edit编辑]",
|
||||
helper: "前往 [获取API令牌](https://dash.cloudflare.com/profile/api-tokens),注意是令牌,不是密钥。\n token权限必须包含两条:\n[Zone区域-Zone区域-Edit编辑]\n[Zone区域-DNS-Edit编辑]",
|
||||
required: true,
|
||||
encrypt: true,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
|
||||
|
||||
/**
|
||||
* Fastly Access Plugin
|
||||
* Provides API authentication and HTTP helper for Fastly API endpoints.
|
||||
*/
|
||||
@IsAccess({
|
||||
name: "fastly",
|
||||
title: "Fastly授权",
|
||||
icon: "simple-icons:fastly",
|
||||
desc: "Fastly CDN / TLS Custom Certificates API 授权",
|
||||
})
|
||||
export class FastlyAccess extends BaseAccess {
|
||||
@AccessInput({
|
||||
title: "API Token",
|
||||
component: {
|
||||
placeholder: "Fastly API Key / Token",
|
||||
},
|
||||
helper: "前往 Fastly Account Settings -> Personal Access Tokens 创建 Token",
|
||||
required: true,
|
||||
encrypt: true,
|
||||
})
|
||||
apiKey = "";
|
||||
|
||||
@AccessInput({
|
||||
title: "HTTP代理",
|
||||
component: {
|
||||
placeholder: "http://xxxx.xxx.xx:10811",
|
||||
},
|
||||
helper: "可选:是否使用 HTTP 代理访问 Fastly API",
|
||||
required: false,
|
||||
encrypt: false,
|
||||
})
|
||||
proxy = "";
|
||||
|
||||
@AccessInput({
|
||||
title: "测试",
|
||||
component: {
|
||||
name: "api-test",
|
||||
action: "TestRequest",
|
||||
},
|
||||
helper: "测试授权是否正确",
|
||||
})
|
||||
testRequest = true;
|
||||
|
||||
async onTestRequest() {
|
||||
await this.doRequestApi("/tls/certificates?page[size]=1", null, "get");
|
||||
return "ok";
|
||||
}
|
||||
|
||||
async doRequestApi(url: string, data: any = null, method = "post") {
|
||||
const baseUrl = "https://api.fastly.com";
|
||||
const requestUrl = url.startsWith("http") ? url : `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Fastly-Key": this.apiKey,
|
||||
Accept: "application/vnd.api+json",
|
||||
};
|
||||
|
||||
if (data && (method.toLowerCase() === "post" || method.toLowerCase() === "patch" || method.toLowerCase() === "put")) {
|
||||
headers["Content-Type"] = "application/vnd.api+json";
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.ctx.http.request<any, any>({
|
||||
url: requestUrl,
|
||||
method,
|
||||
headers,
|
||||
data,
|
||||
httpProxy: this.proxy,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
const errorData = e.response?.data;
|
||||
if (errorData) {
|
||||
const errorsStr = JSON.stringify(errorData);
|
||||
this.ctx.logger.error(`Fastly API Error: ${errorsStr}`);
|
||||
throw new Error(`Fastly API 请求失败: ${errorsStr}`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every page of a Fastly JSON:API list endpoint.
|
||||
* ctx.http already unwraps the response to its body, so the item array is at `body.data`.
|
||||
* Fastly paginates these endpoints at 20 items/page by default; without looping only the
|
||||
* first page would be visible in the selectors.
|
||||
*/
|
||||
async listAllJsonApi(path: string, pageSize = 100): Promise<any[]> {
|
||||
const all: any[] = [];
|
||||
const maxPages = 100; // hard cap to avoid an infinite loop if the API misbehaves
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const body = await this.doRequestApi(`${path}${sep}page[number]=${page}&page[size]=${pageSize}`, null, "get");
|
||||
const items = body?.data;
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
break;
|
||||
}
|
||||
all.push(...items);
|
||||
const totalPages = body?.meta?.total_pages;
|
||||
if (totalPages != null ? page >= totalPages : items.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async getServices() {
|
||||
// legacy /service endpoint returns a bare array of objects (not JSON:API)
|
||||
const res = await this.doRequestApi("/service?per_page=200", null, "get");
|
||||
return Array.isArray(res) ? res : res?.data || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every domain configured on the active version of each Fastly service.
|
||||
* These are the names you can actually create a TLS activation for — unlike
|
||||
* `/tls/domains`, which just echoes the SAN entries of your certificates
|
||||
* (so a wildcard cert shows "*.example.com" that no service serves directly).
|
||||
*/
|
||||
async getServiceDomains(): Promise<string[]> {
|
||||
const services = await this.getServices();
|
||||
const domains = new Set<string>();
|
||||
for (const svc of services) {
|
||||
if (!svc?.id) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const activeVersion = this.resolveActiveVersion(svc);
|
||||
if (activeVersion == null) {
|
||||
continue;
|
||||
}
|
||||
const list = await this.doRequestApi(`/service/${svc.id}/version/${activeVersion}/domain`, null, "get");
|
||||
const items: any[] = Array.isArray(list) ? list : list?.data || [];
|
||||
for (const d of items) {
|
||||
if (d?.name) {
|
||||
domains.add(d.name);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.ctx.logger.error(`获取 Fastly 服务 ${svc.id} 域名失败: ${e?.message}`);
|
||||
}
|
||||
}
|
||||
return [...domains];
|
||||
}
|
||||
|
||||
private resolveActiveVersion(svc: any): number | undefined {
|
||||
const versions: any[] = Array.isArray(svc?.versions) ? svc.versions : [];
|
||||
const active = versions.find((v: any) => v?.active === true);
|
||||
if (active?.number != null) {
|
||||
return active.number;
|
||||
}
|
||||
if (typeof svc?.version === "number") {
|
||||
return svc.version;
|
||||
}
|
||||
const numbers = versions.map((v: any) => v?.number).filter((n: any) => typeof n === "number");
|
||||
return numbers.length ? Math.max(...numbers) : undefined;
|
||||
}
|
||||
|
||||
async getTlsConfigurations() {
|
||||
return this.listAllJsonApi("/tls/configurations");
|
||||
}
|
||||
|
||||
async getTlsDomains() {
|
||||
return this.listAllJsonApi("/tls/domains");
|
||||
}
|
||||
|
||||
async getCertificates() {
|
||||
return this.listAllJsonApi("/tls/certificates");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a brand new custom TLS certificate on Fastly.
|
||||
* Fastly requires the private key to exist first, so this is a 2-step flow:
|
||||
* upload (or reuse) the key at /tls/private_keys, then create the certificate
|
||||
* referencing it. Returns the new tls_certificate id.
|
||||
*/
|
||||
async createCertificate(certPem: string, keyPem: string, name?: string): Promise<string> {
|
||||
const privateKeyId = await this.ensurePrivateKey(keyPem, name);
|
||||
|
||||
this.ctx.logger.info("开始上传证书到 Fastly...");
|
||||
const certPayload = {
|
||||
data: {
|
||||
type: "tls_certificate",
|
||||
attributes: {
|
||||
cert_blob: certPem,
|
||||
...(name && { name }),
|
||||
},
|
||||
relationships: {
|
||||
tls_private_key: {
|
||||
data: {
|
||||
type: "tls_private_key",
|
||||
id: privateKeyId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const certRes = await this.doRequestApi("/tls/certificates", certPayload, "post");
|
||||
return certRes?.data?.id || "";
|
||||
} catch (e: any) {
|
||||
// Fastly also rejects a byte-identical certificate that is already stored.
|
||||
const message: string = e?.message || "";
|
||||
if (!/already exists|already in use/i.test(message)) {
|
||||
throw e;
|
||||
}
|
||||
const existingId = FastlyAccess.extractExistingId(message);
|
||||
if (existingId) {
|
||||
this.ctx.logger.info(`Fastly 已存在相同证书,复用 certId: ${existingId}`);
|
||||
return existingId;
|
||||
}
|
||||
throw new Error(`Fastly 证书已存在:${message}。` + "该证书可能此前已上传。请在【Fastly-上传证书到Fastly】任务中填写该证书ID以走更新(PATCH)流程。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing custom TLS certificate on Fastly (PATCH).
|
||||
* Only cert_blob is sent; the private key stays linked to the certificate.
|
||||
* Fastly requires the replacement certificate to use the same private key as the
|
||||
* original, so renewals feeding this must reuse their key.
|
||||
* Returns the tls_certificate id (falls back to the passed id if the API omits it).
|
||||
*/
|
||||
async updateCertificate(certificateId: string, certPem: string, name?: string): Promise<string> {
|
||||
const payload = {
|
||||
data: {
|
||||
type: "tls_certificate",
|
||||
id: certificateId,
|
||||
attributes: {
|
||||
cert_blob: certPem,
|
||||
...(name && { name }),
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = await this.doRequestApi(`/tls/certificates/${certificateId}`, payload, "patch");
|
||||
return res?.data?.id || certificateId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a certificate to one TLS domain under a TLS configuration.
|
||||
* A tls_activation is per (certificate, configuration, domain), and only one may
|
||||
* exist per domain+configuration. If one is already there (e.g. on renewal) we
|
||||
* PATCH it onto the new certificate instead of failing on a duplicate POST.
|
||||
*/
|
||||
async deployActivation(certificateId: string, configurationId: string, domainId: string): Promise<{ id: string; action: "created" | "updated" }> {
|
||||
const existing = await this.findActivation(configurationId, domainId);
|
||||
if (existing?.id) {
|
||||
const res = await this.doRequestApi(
|
||||
`/tls/activations/${existing.id}`,
|
||||
{
|
||||
data: {
|
||||
type: "tls_activation",
|
||||
id: existing.id,
|
||||
relationships: {
|
||||
tls_certificate: { data: { type: "tls_certificate", id: certificateId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"patch"
|
||||
);
|
||||
return { id: res?.data?.id || existing.id, action: "updated" };
|
||||
}
|
||||
|
||||
const res = await this.doRequestApi(
|
||||
"/tls/activations",
|
||||
{
|
||||
data: {
|
||||
type: "tls_activation",
|
||||
relationships: {
|
||||
tls_certificate: { data: { type: "tls_certificate", id: certificateId } },
|
||||
tls_configuration: { data: { type: "tls_configuration", id: configurationId } },
|
||||
tls_domain: { data: { type: "tls_domain", id: domainId } },
|
||||
},
|
||||
},
|
||||
},
|
||||
"post"
|
||||
);
|
||||
return { id: res?.data?.id || "", action: "created" };
|
||||
}
|
||||
|
||||
private async findActivation(configurationId: string, domainId: string): Promise<any | undefined> {
|
||||
// encodeURIComponent 按规范保留 `*`,但 Fastly 过滤条件要求通配符编码为 `%2A`。
|
||||
const encodedDomainId = encodeURIComponent(domainId).replace(/\*/g, "%2A");
|
||||
const body = await this.doRequestApi(`/tls/activations?filter[tls_domain.id]=${encodedDomainId}&page[size]=100`, null, "get");
|
||||
const items: any[] = Array.isArray(body?.data) ? body.data : [];
|
||||
if (items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
// Prefer the activation tied to this configuration; fall back to the sole match.
|
||||
return items.find((a: any) => a?.relationships?.tls_configuration?.data?.id === configurationId) ?? (items.length === 1 ? items[0] : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the private key, or reuses the one Fastly already stores.
|
||||
* Fastly deduplicates TLS private keys by content: re-uploading an existing key
|
||||
* fails with 400 "Key already exists: '<id>'" (common on renewal when the key is
|
||||
* reused). That id is the existing tls_private_key resource, so recover and use it.
|
||||
*/
|
||||
private async ensurePrivateKey(keyPem: string, name?: string): Promise<string> {
|
||||
this.ctx.logger.info("开始上传私钥到 Fastly...");
|
||||
try {
|
||||
const keyRes = await this.doRequestApi("/tls/private_keys", { data: { type: "tls_private_key", attributes: { key: keyPem, ...(name && { name }) } } }, "post");
|
||||
const id = keyRes?.data?.id;
|
||||
if (!id) {
|
||||
throw new Error("Fastly 私钥上传失败,未获取到 private key ID");
|
||||
}
|
||||
this.ctx.logger.info(`Fastly 私钥上传成功, privateKeyId: ${id}`);
|
||||
return id;
|
||||
} catch (e: any) {
|
||||
const message: string = e?.message || "";
|
||||
if (!/already exists/i.test(message)) {
|
||||
throw e;
|
||||
}
|
||||
const idFromError = FastlyAccess.extractExistingId(message);
|
||||
if (idFromError) {
|
||||
this.ctx.logger.info(`Fastly 已存在相同私钥,复用 privateKeyId: ${idFromError}`);
|
||||
return idFromError;
|
||||
}
|
||||
if (name) {
|
||||
const keys = await this.listAllJsonApi("/tls/private_keys");
|
||||
const matched = keys.filter((k: any) => k?.attributes?.name === name);
|
||||
if (matched.length === 1) {
|
||||
this.ctx.logger.info(`Fastly 已存在同名私钥,复用 privateKeyId: ${matched[0].id}`);
|
||||
return matched[0].id;
|
||||
}
|
||||
}
|
||||
throw new Error(`Fastly 私钥已存在但无法确定其ID:${message}。` + "请在【Fastly-上传证书到Fastly】任务中填写已有证书ID以走更新(PATCH)流程," + "或在 Fastly 控制台删除该孤立私钥后重试。");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the existing resource id out of a Fastly "already exists" error, e.g.
|
||||
* `... Key already exists: 'EO8Drv7EYjThQ4DUPo4Ot0'`. Returns undefined when the
|
||||
* message carries no id (the caller then falls back or surfaces guidance).
|
||||
*/
|
||||
private static extractExistingId(message: string): string | undefined {
|
||||
return message.match(/already exists:\s*['"]?([A-Za-z0-9_-]{8,})['"]?/i)?.[1];
|
||||
}
|
||||
}
|
||||
|
||||
new FastlyAccess();
|
||||
@@ -0,0 +1,505 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { FastlyAccess } from "./access.js";
|
||||
import { FastlyUploadCertPlugin } from "./plugins/plugin-upload-cert.js";
|
||||
import { FastlyPurgeCachePlugin } from "./plugins/plugin-purge-cache.js";
|
||||
import { FastlyDeployCertPlugin } from "./plugins/plugin-deploy-to-service.js";
|
||||
import { FastlyRefreshCertPlugin } from "./plugins/plugin-refresh-cert.js";
|
||||
const mockCert = {
|
||||
crt: "-----BEGIN CERTIFICATE-----\nMOCKCERT\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMOCKINTERMEDIATE\n-----END CERTIFICATE-----",
|
||||
key: "-----BEGIN PRIVATE KEY-----\nMOCKKEY\n-----END PRIVATE KEY-----",
|
||||
};
|
||||
|
||||
// Real FastlyAccess so plugins exercise its createCertificate/updateCertificate
|
||||
// helpers; only the low-level doRequestApi is stubbed.
|
||||
function fakeAccess(doRequestApi: (path: string, payload: any, method: string) => any) {
|
||||
const access = new FastlyAccess();
|
||||
(access as any).ctx = { logger: { info: () => {}, error: () => {} } };
|
||||
(access as any).doRequestApi = doRequestApi;
|
||||
return access;
|
||||
}
|
||||
|
||||
describe("FastlyAccess", () => {
|
||||
it("should build correct request headers and URL for POST", async () => {
|
||||
const access = new FastlyAccess();
|
||||
access.apiKey = "test-fastly-key";
|
||||
|
||||
let capturedReq: any = null;
|
||||
(access as any).ctx = {
|
||||
http: {
|
||||
request: async (req: any) => {
|
||||
capturedReq = req;
|
||||
return { data: { id: "tls_cert_123" } };
|
||||
},
|
||||
},
|
||||
logger: { info: () => {}, error: () => {} },
|
||||
};
|
||||
|
||||
const res = await access.doRequestApi("/tls/certificates", { test: true }, "post");
|
||||
|
||||
assert.equal(capturedReq.url, "https://api.fastly.com/tls/certificates");
|
||||
assert.equal(capturedReq.headers["Fastly-Key"], "test-fastly-key");
|
||||
assert.equal(capturedReq.headers["Accept"], "application/vnd.api+json");
|
||||
assert.equal(capturedReq.headers["Content-Type"], "application/vnd.api+json");
|
||||
assert.equal(res.data.id, "tls_cert_123");
|
||||
});
|
||||
|
||||
it("should NOT set Content-Type for GET requests", async () => {
|
||||
const access = new FastlyAccess();
|
||||
access.apiKey = "test-key";
|
||||
|
||||
let capturedReq: any = null;
|
||||
(access as any).ctx = {
|
||||
http: {
|
||||
request: async (req: any) => {
|
||||
capturedReq = req;
|
||||
return { data: [] };
|
||||
},
|
||||
},
|
||||
logger: { info: () => {}, error: () => {} },
|
||||
};
|
||||
|
||||
await access.doRequestApi("/tls/certificates?page[size]=1", null, "get");
|
||||
assert.equal(capturedReq.headers["Content-Type"], undefined);
|
||||
assert.equal(capturedReq.headers["Fastly-Key"], "test-key");
|
||||
});
|
||||
|
||||
it("should pass proxy to http request when proxy is set", async () => {
|
||||
const access = new FastlyAccess();
|
||||
access.apiKey = "test-key";
|
||||
access.proxy = "http://proxy.example.com:3128";
|
||||
|
||||
let capturedReq: any = null;
|
||||
(access as any).ctx = {
|
||||
http: {
|
||||
request: async (req: any) => {
|
||||
capturedReq = req;
|
||||
return {};
|
||||
},
|
||||
},
|
||||
logger: { info: () => {}, error: () => {} },
|
||||
};
|
||||
|
||||
await access.doRequestApi("/tls/certificates", null, "get");
|
||||
assert.equal(capturedReq.httpProxy, "http://proxy.example.com:3128");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FastlyAccess list helpers", () => {
|
||||
function mockAccess(handler: (url: string) => any) {
|
||||
const access = new FastlyAccess();
|
||||
access.apiKey = "k";
|
||||
(access as any).ctx = {
|
||||
http: { request: async (req: any) => handler(req.url) },
|
||||
logger: { info: () => {}, error: () => {} },
|
||||
};
|
||||
return access;
|
||||
}
|
||||
|
||||
it("getCertificates returns body.data (not body.data.data) and aggregates all pages", async () => {
|
||||
const urls: string[] = [];
|
||||
const access = mockAccess((url: string) => {
|
||||
urls.push(url);
|
||||
const page = Number(url.match(/page\[number\]=(\d+)/)?.[1]);
|
||||
// 3 pages total, 2 items each
|
||||
const pageItems = [
|
||||
[{ id: "c1" }, { id: "c2" }],
|
||||
[{ id: "c3" }, { id: "c4" }],
|
||||
[{ id: "c5" }, { id: "c6" }],
|
||||
];
|
||||
return { data: pageItems[page - 1] ?? [], meta: { total_pages: 3 } };
|
||||
});
|
||||
|
||||
const list = await access.getCertificates();
|
||||
assert.deepEqual(
|
||||
list.map((x: any) => x.id),
|
||||
["c1", "c2", "c3", "c4", "c5", "c6"]
|
||||
);
|
||||
assert.equal(urls.length, 3);
|
||||
});
|
||||
|
||||
it("stops paginating on a short page when total_pages is absent", async () => {
|
||||
let calls = 0;
|
||||
const access = mockAccess(() => {
|
||||
calls++;
|
||||
// single short page (< pageSize) => no more requests
|
||||
return { data: [{ id: "d1" }] };
|
||||
});
|
||||
|
||||
const list = await access.getTlsDomains();
|
||||
assert.deepEqual(list, [{ id: "d1" }]);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
it("getServices unwraps a bare array response", async () => {
|
||||
const access = mockAccess(() => [{ id: "svc1", name: "a" }]);
|
||||
const list = await access.getServices();
|
||||
assert.deepEqual(list, [{ id: "svc1", name: "a" }]);
|
||||
});
|
||||
|
||||
it("getServiceDomains collects domains from each service's active version", async () => {
|
||||
const access = mockAccess((url: string) => {
|
||||
if (url.endsWith("/service?per_page=200")) {
|
||||
return [
|
||||
{
|
||||
id: "svc1",
|
||||
versions: [
|
||||
{ number: 1, active: false },
|
||||
{ number: 2, active: true },
|
||||
],
|
||||
},
|
||||
{ id: "svc2", version: 5 },
|
||||
];
|
||||
}
|
||||
if (url.endsWith("/service/svc1/version/2/domain")) {
|
||||
return [{ name: "www.g0l.net" }, { name: "img.g0l.net" }];
|
||||
}
|
||||
if (url.endsWith("/service/svc2/version/5/domain")) {
|
||||
return [{ name: "www.g0l.net" }, { name: "api.other.net" }];
|
||||
}
|
||||
throw new Error(`unexpected ${url}`);
|
||||
});
|
||||
|
||||
const domains = await access.getServiceDomains();
|
||||
assert.deepEqual(domains.sort(), ["api.other.net", "img.g0l.net", "www.g0l.net"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FastlyUploadCertPlugin - new certificate (2-step flow)", () => {
|
||||
it("should upload private key first then create certificate with relationship", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
plugin.name = "my-cert";
|
||||
|
||||
const calls: { path: string; payload: any; method: string }[] = [];
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload, method });
|
||||
if (path === "/tls/private_keys") {
|
||||
return { data: { id: "pk_abc123" } };
|
||||
}
|
||||
if (path === "/tls/certificates") {
|
||||
return { data: { id: "tls_cert_new_999" } };
|
||||
}
|
||||
throw new Error(`Unexpected call: ${path}`);
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
// Step 1: private key upload
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].path, "/tls/private_keys");
|
||||
assert.equal(calls[0].method, "post");
|
||||
assert.equal(calls[0].payload.data.type, "tls_private_key");
|
||||
assert.equal(calls[0].payload.data.attributes.key, mockCert.key);
|
||||
assert.equal(calls[0].payload.data.attributes.name, "my-cert");
|
||||
|
||||
// Step 2: certificate upload with relationship
|
||||
assert.equal(calls[1].path, "/tls/certificates");
|
||||
assert.equal(calls[1].method, "post");
|
||||
assert.equal(calls[1].payload.data.type, "tls_certificate");
|
||||
assert.equal(calls[1].payload.data.attributes.cert_blob, mockCert.crt);
|
||||
assert.equal(calls[1].payload.data.relationships.tls_private_key.data.id, "pk_abc123");
|
||||
assert.equal(calls[1].payload.data.relationships.tls_private_key.data.type, "tls_private_key");
|
||||
|
||||
assert.equal(plugin.fastlyCertId, "tls_cert_new_999");
|
||||
});
|
||||
|
||||
it("should throw if private key upload returns no ID", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
|
||||
const mockAccess = fakeAccess(async () => ({ data: {} })); // no id returned
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await assert.rejects(() => plugin.execute(), /Fastly 私钥上传失败,未获取到 private key ID/);
|
||||
});
|
||||
|
||||
it("reuses the existing private key when Fastly reports it already exists", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
|
||||
const calls: { path: string; payload: any; method: string }[] = [];
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload, method });
|
||||
if (path === "/tls/private_keys") {
|
||||
throw new Error('Fastly API 请求失败: {"errors":[{"title":"Can\'t create key","detail":"Key already exists: \'EO8Drv7EYjThQ4DUPo4Ot0\'"}]}');
|
||||
}
|
||||
if (path === "/tls/certificates") {
|
||||
return { data: { id: "tls_cert_new_1" } };
|
||||
}
|
||||
throw new Error(`Unexpected call: ${path}`);
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[1].path, "/tls/certificates");
|
||||
assert.equal(calls[1].payload.data.relationships.tls_private_key.data.id, "EO8Drv7EYjThQ4DUPo4Ot0");
|
||||
assert.equal(plugin.fastlyCertId, "tls_cert_new_1");
|
||||
});
|
||||
|
||||
it("reuses the existing certificate when Fastly reports it already exists", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string) => {
|
||||
if (path === "/tls/private_keys") {
|
||||
return { data: { id: "pk_1" } };
|
||||
}
|
||||
if (path === "/tls/certificates") {
|
||||
throw new Error('Fastly API 请求失败: {"errors":[{"title":"Can\'t create certificate","detail":"Certificate already exists: \'aBcDeFgHiJkLmNoPqRsTuV\'"}]}');
|
||||
}
|
||||
throw new Error(`Unexpected call: ${path}`);
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
assert.equal(plugin.fastlyCertId, "aBcDeFgHiJkLmNoPqRsTuV");
|
||||
});
|
||||
|
||||
it("surfaces guidance when the certificate already exists without a recoverable id", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string) => {
|
||||
if (path === "/tls/private_keys") {
|
||||
return { data: { id: "pk_1" } };
|
||||
}
|
||||
if (path === "/tls/certificates") {
|
||||
throw new Error('Fastly API 请求失败: {"errors":[{"detail":"cert_blob is already in use"}]}');
|
||||
}
|
||||
throw new Error(`Unexpected call: ${path}`);
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await assert.rejects(() => plugin.execute(), /填写该证书ID以走更新\(PATCH\)流程/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FastlyUploadCertPlugin - update existing certificate (PATCH)", () => {
|
||||
it("should PATCH existing certificate with only cert_blob, no private key step", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
plugin.certificateId = "tls_cert_existing_123";
|
||||
plugin.name = "updated-cert";
|
||||
|
||||
const calls: { path: string; payload: any; method: string }[] = [];
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload, method });
|
||||
return { data: { id: "tls_cert_existing_123" } };
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
// Only 1 API call for update
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].path, "/tls/certificates/tls_cert_existing_123");
|
||||
assert.equal(calls[0].method, "patch");
|
||||
assert.equal(calls[0].payload.data.type, "tls_certificate");
|
||||
assert.equal(calls[0].payload.data.id, "tls_cert_existing_123");
|
||||
assert.equal(calls[0].payload.data.attributes.cert_blob, mockCert.crt);
|
||||
assert.equal(calls[0].payload.data.attributes.name, "updated-cert");
|
||||
// No relationships on PATCH
|
||||
assert.equal(calls[0].payload.data.relationships, undefined);
|
||||
|
||||
assert.equal(plugin.fastlyCertId, "tls_cert_existing_123");
|
||||
});
|
||||
|
||||
it("should fallback fastlyCertId to certificateId when response has no id", async () => {
|
||||
const plugin = new FastlyUploadCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
plugin.certificateId = "tls_cert_fallback_id";
|
||||
|
||||
const mockAccess = fakeAccess(async () => ({ data: {} })); // no id in response
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
assert.equal(plugin.fastlyCertId, "tls_cert_fallback_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FastlyPurgeCachePlugin", () => {
|
||||
it("should call purge_all on the provided serviceId", async () => {
|
||||
const plugin = new FastlyPurgeCachePlugin();
|
||||
plugin.accessId = "access-1";
|
||||
plugin.serviceId = "svc_123";
|
||||
|
||||
let capturedUrl = "";
|
||||
let capturedMethod = "";
|
||||
|
||||
const mockAccess = {
|
||||
doRequestApi: async (path: string, payload: any, method: string) => {
|
||||
capturedUrl = path;
|
||||
capturedMethod = method;
|
||||
return { data: { status: "ok" } };
|
||||
},
|
||||
};
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
assert.equal(capturedUrl, "/service/svc_123/purge_all");
|
||||
assert.equal(capturedMethod, "post");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FastlyDeployCertPlugin", () => {
|
||||
it("creates one tls_activation per domain when none exists yet", async () => {
|
||||
const plugin = new FastlyDeployCertPlugin();
|
||||
plugin.accessId = "access-1";
|
||||
plugin.cert = "cert_1";
|
||||
plugin.domainIds = ["*.g0l.net", "g0l.net"];
|
||||
plugin.configurationId = "cfg_1";
|
||||
|
||||
const calls: { path: string; payload: any; method: string }[] = [];
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload, method });
|
||||
if (method === "get") {
|
||||
return { data: [] }; // no existing activation
|
||||
}
|
||||
return { data: { id: "act_new" } };
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
const posts = calls.filter(c => c.method === "post");
|
||||
assert.equal(posts.length, 2);
|
||||
assert.equal(posts[0].path, "/tls/activations");
|
||||
assert.equal(posts[0].payload.data.relationships.tls_certificate.data.id, "cert_1");
|
||||
assert.equal(posts[0].payload.data.relationships.tls_configuration.data.id, "cfg_1");
|
||||
assert.equal(posts[0].payload.data.relationships.tls_domain.data.id, "*.g0l.net");
|
||||
assert.equal(posts[1].payload.data.relationships.tls_domain.data.id, "g0l.net");
|
||||
// domain filter is url-encoded (wildcard -> %2A)
|
||||
assert.ok(calls[0].path.startsWith("/tls/activations?filter[tls_domain.id]=%2A.g0l.net"));
|
||||
});
|
||||
|
||||
it("patches the existing activation onto the new cert (renewal)", async () => {
|
||||
const plugin = new FastlyDeployCertPlugin();
|
||||
plugin.accessId = "access-1";
|
||||
plugin.cert = "cert_new";
|
||||
plugin.domainIds = ["g0l.net"];
|
||||
plugin.configurationId = "cfg_1";
|
||||
|
||||
const calls: { path: string; payload: any; method: string }[] = [];
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload, method });
|
||||
if (method === "get") {
|
||||
return { data: [{ id: "act_1", relationships: { tls_configuration: { data: { id: "cfg_1" } } } }] };
|
||||
}
|
||||
return { data: { id: "act_1" } };
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
const write = calls.find(c => c.method !== "get")!;
|
||||
assert.equal(write.method, "patch");
|
||||
assert.equal(write.path, "/tls/activations/act_1");
|
||||
assert.equal(write.payload.data.id, "act_1");
|
||||
assert.equal(write.payload.data.relationships.tls_certificate.data.id, "cert_new");
|
||||
assert.equal(write.payload.data.relationships.tls_configuration, undefined);
|
||||
});
|
||||
|
||||
it("uploads a raw CertInfo to Fastly first, then binds the new cert id", async () => {
|
||||
const plugin = new FastlyDeployCertPlugin();
|
||||
plugin.accessId = "access-1";
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.name = "my-cert";
|
||||
plugin.domainIds = ["dom_1"];
|
||||
plugin.configurationId = "cfg_1";
|
||||
|
||||
const calls: { path: string; payload: any; method: string }[] = [];
|
||||
|
||||
const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload, method });
|
||||
if (path === "/tls/private_keys") {
|
||||
return { data: { id: "pk_1" } };
|
||||
}
|
||||
if (path === "/tls/certificates") {
|
||||
return { data: { id: "tls_cert_uploaded_1" } };
|
||||
}
|
||||
if (method === "get") {
|
||||
return { data: [] };
|
||||
}
|
||||
return { data: { id: "act_new" } };
|
||||
});
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
assert.equal(calls[0].path, "/tls/private_keys");
|
||||
assert.equal(calls[1].path, "/tls/certificates");
|
||||
assert.equal(calls[1].payload.data.attributes.cert_blob, mockCert.crt);
|
||||
const post = calls.find(c => c.path === "/tls/activations" && c.method === "post")!;
|
||||
assert.equal(post.payload.data.relationships.tls_certificate.data.id, "tls_cert_uploaded_1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FastlyRefreshCertPlugin", () => {
|
||||
it("should update multiple certificates via PATCH", async () => {
|
||||
const plugin = new FastlyRefreshCertPlugin();
|
||||
plugin.cert = mockCert as any;
|
||||
plugin.accessId = "access-1";
|
||||
plugin.certList = ["cert_a", "cert_b"];
|
||||
|
||||
const calls: { path: string; payload: any }[] = [];
|
||||
|
||||
const mockAccess = {
|
||||
doRequestApi: async (path: string, payload: any, method: string) => {
|
||||
calls.push({ path, payload });
|
||||
return { data: { id: path.split("/").pop() } };
|
||||
},
|
||||
};
|
||||
|
||||
(plugin as any).getAccess = async () => mockAccess;
|
||||
(plugin as any).logger = { info: () => {}, error: () => {} };
|
||||
|
||||
await plugin.execute();
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(calls[0].path, "/tls/certificates/cert_a");
|
||||
assert.equal(calls[0].payload.data.id, "cert_a");
|
||||
assert.equal(calls[0].payload.data.attributes.cert_blob, mockCert.crt);
|
||||
|
||||
assert.equal(calls[1].path, "/tls/certificates/cert_b");
|
||||
assert.equal(calls[1].payload.data.id, "cert_b");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./access.js";
|
||||
export * from "./plugins/index.js";
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./plugin-upload-cert.js";
|
||||
export * from "./plugin-purge-cache.js";
|
||||
export * from "./plugin-deploy-to-service.js";
|
||||
export * from "./plugin-refresh-cert.js";
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
||||
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
|
||||
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
|
||||
import { FastlyAccess } from "../access.js";
|
||||
|
||||
@IsTaskPlugin({
|
||||
name: "FastlyDeployCert",
|
||||
title: "Fastly-部署TLS激活",
|
||||
desc: "部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名)",
|
||||
icon: "simple-icons:fastly",
|
||||
group: pluginGroups.cdn.key,
|
||||
default: {
|
||||
strategy: {
|
||||
runStrategy: RunStrategy.SkipWhenSucceed,
|
||||
},
|
||||
},
|
||||
})
|
||||
export class FastlyDeployCertPlugin extends AbstractTaskPlugin {
|
||||
@TaskInput({
|
||||
title: "域名证书",
|
||||
helper: "选择前置任务输出的域名证书(将自动上传到 Fastly)," + "或选择前置【Fastly-上传证书到Fastly】任务输出的 Fastly 证书ID。",
|
||||
component: {
|
||||
name: "output-selector",
|
||||
from: [...CertApplyPluginNames, "FastlyUploadCert"],
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
cert!: CertInfo | string;
|
||||
|
||||
@TaskInput({
|
||||
title: "证书名称",
|
||||
helper: "可选。当传入的是域名证书需要先上传时,作为 Fastly 上的自定义证书名称/标签",
|
||||
component: {
|
||||
placeholder: "例如: my-fastly-cert",
|
||||
},
|
||||
required: false,
|
||||
})
|
||||
name = "";
|
||||
|
||||
@TaskInput({
|
||||
title: "Access授权",
|
||||
helper: "Fastly 授权凭证",
|
||||
component: {
|
||||
name: "access-selector",
|
||||
type: "fastly",
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
accessId!: string;
|
||||
|
||||
@TaskInput(
|
||||
createRemoteSelectInputDefine({
|
||||
title: "TLS 域名",
|
||||
helper:
|
||||
"选择要绑定证书的域名,可多选(将为每个域名创建或更新一个 TLS 激活)。" +
|
||||
"列表为你 Fastly 服务上配置的域名,请选择被证书覆盖的具体主机名(如 www.example.com)," +
|
||||
"而非通配 *.example.com(除非确有服务使用通配域名)。列表没有的也可直接输入。",
|
||||
action: FastlyDeployCertPlugin.prototype.onGetTlsDomainList.name,
|
||||
pager: false,
|
||||
search: false,
|
||||
required: true,
|
||||
})
|
||||
)
|
||||
domainIds!: string[];
|
||||
|
||||
@TaskInput(
|
||||
createRemoteSelectInputDefine({
|
||||
title: "TLS 配置",
|
||||
helper: "选择关联的 TLS 配置",
|
||||
action: FastlyDeployCertPlugin.prototype.onGetTlsConfigurationList.name,
|
||||
pager: false,
|
||||
search: false,
|
||||
single: true,
|
||||
required: true,
|
||||
})
|
||||
)
|
||||
configurationId!: string;
|
||||
|
||||
async onInstance() {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
|
||||
// remote-select hands back an array; tolerate a single value too (older config / single:true).
|
||||
const rawDomains: any = this.domainIds ?? (this as any).domainId;
|
||||
const domainIds: string[] = Array.isArray(rawDomains) ? rawDomains.filter(Boolean) : rawDomains ? [rawDomains] : [];
|
||||
const rawConfig: any = this.configurationId;
|
||||
const configurationId: string = Array.isArray(rawConfig) ? rawConfig[0] : rawConfig;
|
||||
|
||||
if (domainIds.length === 0 || !configurationId) {
|
||||
throw new Error("请提供完整的 TLS 域名和 TLS 配置");
|
||||
}
|
||||
|
||||
// `cert` is a string when it comes from a FastlyUploadCert step (already a Fastly
|
||||
// tls_certificate id); otherwise it is a raw CertInfo that must be uploaded first.
|
||||
let certificateId: string;
|
||||
if (typeof this.cert === "string") {
|
||||
certificateId = this.cert;
|
||||
} else {
|
||||
const certName = this.name && this.name.trim() ? this.name.trim() : undefined;
|
||||
this.logger.info("未检测到 Fastly 证书ID,先上传证书到 Fastly...");
|
||||
certificateId = await access.createCertificate(this.cert.crt, this.cert.key, certName);
|
||||
this.logger.info(`证书上传成功, Fastly 证书ID: ${certificateId}`);
|
||||
}
|
||||
|
||||
if (!certificateId) {
|
||||
throw new Error("未能获取 Fastly 证书ID");
|
||||
}
|
||||
|
||||
for (const domainId of domainIds) {
|
||||
this.logger.info(`开始部署 Fastly TLS 激活 (域名: ${domainId})...`);
|
||||
const { id, action } = await access.deployActivation(certificateId, configurationId, domainId);
|
||||
this.logger.info(`Fastly TLS 激活${action === "updated" ? "更新" : "创建"}成功 (域名: ${domainId}), 激活ID: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onGetTlsDomainList() {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
|
||||
const [serviceDomains, tlsDomains] = await Promise.all([access.getServiceDomains().catch(() => [] as string[]), access.getTlsDomains().catch(() => [] as any[])]);
|
||||
|
||||
const options: { label: string; value: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Domains actually served by your Fastly services — these can be activated.
|
||||
for (const name of serviceDomains) {
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
options.push({ label: name, value: name });
|
||||
}
|
||||
}
|
||||
|
||||
// TLS domains from certificates/subscriptions (incl. wildcards) as extra choices.
|
||||
for (const item of tlsDomains || []) {
|
||||
const name = item?.id;
|
||||
if (name && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
options.push({ label: `${name} (来自证书)`, value: name });
|
||||
}
|
||||
}
|
||||
|
||||
return { list: options };
|
||||
}
|
||||
|
||||
async onGetTlsConfigurationList() {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
const list = await access.getTlsConfigurations();
|
||||
|
||||
if (!list || list.length === 0) {
|
||||
return { list: [] };
|
||||
}
|
||||
|
||||
const options = list.map((item: any) => {
|
||||
return {
|
||||
label: `${item.attributes?.name || "Unnamed Configuration"} (${item.id})`,
|
||||
value: item.id,
|
||||
};
|
||||
});
|
||||
|
||||
return { list: options };
|
||||
}
|
||||
}
|
||||
|
||||
new FastlyDeployCertPlugin();
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
||||
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
|
||||
import { FastlyAccess } from "../access.js";
|
||||
|
||||
@IsTaskPlugin({
|
||||
name: "FastlyPurgeCache",
|
||||
title: "Fastly-清除缓存",
|
||||
desc: "清除 Fastly 指定 Service 的所有缓存 (Purge All)",
|
||||
icon: "simple-icons:fastly",
|
||||
group: pluginGroups.cdn.key,
|
||||
default: {
|
||||
strategy: {
|
||||
runStrategy: RunStrategy.SkipWhenSucceed,
|
||||
},
|
||||
},
|
||||
})
|
||||
export class FastlyPurgeCachePlugin extends AbstractTaskPlugin {
|
||||
@TaskInput({
|
||||
title: "Access授权",
|
||||
helper: "Fastly 授权凭证",
|
||||
component: {
|
||||
name: "access-selector",
|
||||
type: "fastly",
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
accessId!: string;
|
||||
|
||||
@TaskInput(
|
||||
createRemoteSelectInputDefine({
|
||||
title: "Service (服务)",
|
||||
helper: "选择要清理所有缓存的 Fastly 服务",
|
||||
action: FastlyPurgeCachePlugin.prototype.onGetServiceList.name,
|
||||
pager: false,
|
||||
search: false,
|
||||
required: true,
|
||||
})
|
||||
)
|
||||
serviceId!: string;
|
||||
|
||||
async onInstance() {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
|
||||
if (!this.serviceId) {
|
||||
throw new Error("请选择要清理缓存的 Service");
|
||||
}
|
||||
|
||||
this.logger.info(`开始清理 Fastly 服务 [${this.serviceId}] 的所有缓存...`);
|
||||
|
||||
// POST /service/{service_id}/purge_all — no body; auth is via the Fastly-Key header
|
||||
await access.doRequestApi(`/service/${this.serviceId}/purge_all`, null, "post");
|
||||
|
||||
this.logger.info(`清理 Fastly 服务 [${this.serviceId}] 缓存成功`);
|
||||
}
|
||||
|
||||
async onGetServiceList() {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
const services = await access.getServices();
|
||||
|
||||
if (!services || services.length === 0) {
|
||||
return { list: [] };
|
||||
}
|
||||
|
||||
const options = services.map((item: any) => {
|
||||
return {
|
||||
label: `${item.name} (${item.id})`,
|
||||
value: item.id,
|
||||
};
|
||||
});
|
||||
|
||||
return { list: options };
|
||||
}
|
||||
}
|
||||
|
||||
new FastlyPurgeCachePlugin();
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline";
|
||||
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
|
||||
import { createRemoteSelectInputDefine } from "@certd/plugin-lib";
|
||||
import { FastlyAccess } from "../access.js";
|
||||
|
||||
@IsTaskPlugin({
|
||||
name: "FastlyRefreshCert",
|
||||
title: "Fastly-更新证书",
|
||||
desc: "自动更新 Fastly CDN 上的已有自定义证书",
|
||||
icon: "simple-icons:fastly",
|
||||
group: pluginGroups.cdn.key,
|
||||
default: {
|
||||
strategy: {
|
||||
runStrategy: RunStrategy.SkipWhenSucceed,
|
||||
},
|
||||
},
|
||||
})
|
||||
export class FastlyRefreshCertPlugin extends AbstractTaskPlugin {
|
||||
@TaskInput({
|
||||
title: "域名证书",
|
||||
helper: "请选择前置任务输出的域名证书",
|
||||
component: {
|
||||
name: "output-selector",
|
||||
from: [...CertApplyPluginNames],
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
cert!: CertInfo;
|
||||
|
||||
@TaskInput({
|
||||
title: "Access授权",
|
||||
helper: "Fastly 授权凭证",
|
||||
component: {
|
||||
name: "access-selector",
|
||||
type: "fastly",
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
accessId!: string;
|
||||
|
||||
@TaskInput(
|
||||
createRemoteSelectInputDefine({
|
||||
title: "证书列表",
|
||||
helper: "选择要更新的 Fastly 证书 (必须是已存在的自定义证书)。" + "注意:Fastly 要求更新后的证书与原证书使用相同私钥,请确保续期时复用私钥(reuse key),否则更新会失败。",
|
||||
action: FastlyRefreshCertPlugin.prototype.onGetCertList.name,
|
||||
pager: false,
|
||||
search: false,
|
||||
required: true,
|
||||
})
|
||||
)
|
||||
certList!: string[];
|
||||
|
||||
async onInstance() {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
const certPem = this.cert.crt;
|
||||
|
||||
if (!this.certList || this.certList.length === 0) {
|
||||
throw new Error("请至少选择一个要更新的证书");
|
||||
}
|
||||
|
||||
for (const certId of this.certList) {
|
||||
this.logger.info(`----------- 开始更新 Fastly 证书:${certId} -----------`);
|
||||
|
||||
const payload: any = {
|
||||
data: {
|
||||
type: "tls_certificate",
|
||||
id: certId,
|
||||
attributes: {
|
||||
cert_blob: certPem,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await access.doRequestApi(`/tls/certificates/${certId}`, payload, "patch");
|
||||
this.logger.info(`----------- 更新 Fastly 证书 ${certId} 成功 -----------`);
|
||||
}
|
||||
|
||||
this.logger.info("Fastly 证书批量更新完成");
|
||||
}
|
||||
|
||||
async onGetCertList() {
|
||||
const access = (await this.getAccess(this.accessId)) as FastlyAccess;
|
||||
const list = await access.getCertificates();
|
||||
|
||||
if (!list || list.length === 0) {
|
||||
throw new Error("没有找到 Fastly 证书");
|
||||
}
|
||||
|
||||
const options = list.map((item: any) => {
|
||||
const name = item.attributes?.name || "Unnamed";
|
||||
const issuedTo = item.attributes?.issued_to || "Unknown Domain";
|
||||
return {
|
||||
label: `${name} - ${issuedTo} (${item.id})`,
|
||||
value: item.id,
|
||||
};
|
||||
});
|
||||
|
||||
return { list: options };
|
||||
}
|
||||
}
|
||||
|
||||
new FastlyRefreshCertPlugin();
|
||||
@@ -0,0 +1,88 @@
|
||||
import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline";
|
||||
import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert";
|
||||
import { FastlyAccess } from "../access.js";
|
||||
|
||||
@IsTaskPlugin({
|
||||
name: "FastlyUploadCert",
|
||||
title: "Fastly-上传证书到Fastly",
|
||||
desc: "部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN",
|
||||
icon: "simple-icons:fastly",
|
||||
group: pluginGroups.cdn.key,
|
||||
default: {
|
||||
strategy: {
|
||||
runStrategy: RunStrategy.SkipWhenSucceed,
|
||||
},
|
||||
},
|
||||
})
|
||||
export class FastlyUploadCertPlugin extends AbstractTaskPlugin {
|
||||
@TaskInput({
|
||||
title: "域名证书",
|
||||
helper: "请选择前置任务输出的域名证书",
|
||||
component: {
|
||||
name: "output-selector",
|
||||
from: [...CertApplyPluginNames],
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
cert!: CertInfo;
|
||||
|
||||
@TaskInput({
|
||||
title: "Access授权",
|
||||
helper: "Fastly 授权凭证",
|
||||
component: {
|
||||
name: "access-selector",
|
||||
type: "fastly",
|
||||
},
|
||||
required: true,
|
||||
})
|
||||
accessId!: string;
|
||||
|
||||
@TaskInput({
|
||||
title: "证书ID (tls_certificate_id)",
|
||||
helper: "可选。若填写则对 Fastly 已有证书进行更新(PATCH);留空则新建上传(POST)。" + "注意:Fastly 更新证书要求新证书与原证书使用相同的私钥,请确保续期时复用私钥(reuse key),否则更新会失败。",
|
||||
component: {
|
||||
placeholder: "例如: tls_cert_xxx (留空表示新建)",
|
||||
},
|
||||
required: false,
|
||||
})
|
||||
certificateId = "";
|
||||
|
||||
@TaskInput({
|
||||
title: "证书名称",
|
||||
helper: "可选。上传到 Fastly 上的自定义证书名称/标签",
|
||||
component: {
|
||||
placeholder: "例如: my-fastly-cert",
|
||||
},
|
||||
required: false,
|
||||
})
|
||||
name = "";
|
||||
|
||||
@TaskOutput({
|
||||
title: "Fastly 证书 ID",
|
||||
})
|
||||
fastlyCertId = "";
|
||||
|
||||
async onInstance() {}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
const { cert, accessId, certificateId, name } = this;
|
||||
const access = (await this.getAccess(accessId)) as FastlyAccess;
|
||||
|
||||
// cert.crt is the full certificate chain (PEM); cert.key is the private key (PEM)
|
||||
const certName = name && name.trim() ? name.trim() : undefined;
|
||||
const targetId = certificateId && certificateId.trim();
|
||||
|
||||
if (targetId) {
|
||||
// Update an existing Fastly certificate in place (same private key required).
|
||||
this.logger.info(`开始更新 Fastly 证书 [${targetId}]...`);
|
||||
this.fastlyCertId = await access.updateCertificate(targetId, cert.crt, certName);
|
||||
this.logger.info(`Fastly 证书更新成功, ID: ${this.fastlyCertId}`);
|
||||
} else {
|
||||
// Create a new Fastly certificate (uploads the private key first).
|
||||
this.fastlyCertId = await access.createCertificate(cert.crt, cert.key, certName);
|
||||
this.logger.info(`Fastly 证书新建成功, ID: ${this.fastlyCertId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new FastlyUploadCertPlugin();
|
||||
@@ -1 +1 @@
|
||||
00:58
|
||||
23:51
|
||||
|
||||
@@ -1 +1 @@
|
||||
01:17
|
||||
00:07
|
||||
|
||||
Reference in New Issue
Block a user