diff --git a/internal/certificate/service.go b/internal/certificate/service.go index 1e9dd462..ac08f449 100644 --- a/internal/certificate/service.go +++ b/internal/certificate/service.go @@ -5,9 +5,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "strconv" "strings" + "time" + "github.com/go-acme/lego/v4/certcrypto" "github.com/usual2970/certimate/internal/app" "github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain/dtos" @@ -176,6 +179,26 @@ func (s *CertificateService) ArchiveFile(ctx context.Context, req *dtos.Certific } } +func (s *CertificateService) ValidateCertificate(ctx context.Context, req *dtos.CertificateValidateCertificateReq) (*dtos.CertificateValidateCertificateResp, error) { + info, err := certs.ParseCertificateFromPEM(req.Certificate) + if err != nil { + return nil, err + } + + if time.Now().After(info.NotAfter) { + return nil, errors.New("证书已过期") + } + + return &dtos.CertificateValidateCertificateResp{ + Domains: strings.Join(info.DNSNames, ";"), + }, nil +} + +func (s *CertificateService) ValidatePrivateKey(ctx context.Context, req *dtos.CertificateValidatePrivateKeyReq) error { + _, err := certcrypto.ParsePEMPrivateKey([]byte(req.PrivateKey)) + return err +} + func buildExpireSoonNotification(certificates []*domain.Certificate) *struct { Subject string Message string diff --git a/internal/domain/dtos/certificate.go b/internal/domain/dtos/certificate.go index 3fe13f16..8c9e80ae 100644 --- a/internal/domain/dtos/certificate.go +++ b/internal/domain/dtos/certificate.go @@ -4,3 +4,28 @@ type CertificateArchiveFileReq struct { CertificateId string `json:"-"` Format string `json:"format"` } + +type CertificateArchiveFileResp struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` +} + +type CertificateValidateCertificateReq struct { + Certificate string `json:"certificate"` +} + +type CertificateValidateCertificateResp struct { + Domains string `json:"domains"` +} + +type CertificateValidatePrivateKeyReq struct { + PrivateKey string `json:"privateKey"` +} + +type CertificateUploadReq struct { + WorkflowId string `json:"workflowId"` + WorkflowNodeId string `json:"workflowNodeId"` + CertificateId string `json:"certificateId"` + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` +} diff --git a/internal/domain/workflow.go b/internal/domain/workflow.go index ff675f08..7803b920 100644 --- a/internal/domain/workflow.go +++ b/internal/domain/workflow.go @@ -29,6 +29,7 @@ const ( WorkflowNodeTypeStart = WorkflowNodeType("start") WorkflowNodeTypeEnd = WorkflowNodeType("end") WorkflowNodeTypeApply = WorkflowNodeType("apply") + WorkflowNodeTypeUpload = WorkflowNodeType("upload") WorkflowNodeTypeDeploy = WorkflowNodeType("deploy") WorkflowNodeTypeNotify = WorkflowNodeType("notify") WorkflowNodeTypeBranch = WorkflowNodeType("branch") @@ -75,6 +76,12 @@ type WorkflowNodeConfigForApply struct { SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(默认值:30) } +type WorkflowNodeConfigForUpload struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` + Domains string `json:"domains"` +} + type WorkflowNodeConfigForDeploy struct { Certificate string `json:"certificate"` // 前序节点输出的证书,形如“${NodeId}#certificate” Provider string `json:"provider"` // 主机提供商 @@ -133,6 +140,14 @@ func (n *WorkflowNode) GetConfigForApply() WorkflowNodeConfigForApply { } } +func (n *WorkflowNode) GetConfigForUpload() WorkflowNodeConfigForUpload { + return WorkflowNodeConfigForUpload{ + Certificate: n.getConfigValueAsString("certificate"), + PrivateKey: n.getConfigValueAsString("privateKey"), + Domains: n.getConfigValueAsString("domains"), + } +} + func (n *WorkflowNode) GetConfigForDeploy() WorkflowNodeConfigForDeploy { return WorkflowNodeConfigForDeploy{ Certificate: n.getConfigValueAsString("certificate"), diff --git a/internal/rest/handlers/certificate.go b/internal/rest/handlers/certificate.go index 26788de6..de5bd3a8 100644 --- a/internal/rest/handlers/certificate.go +++ b/internal/rest/handlers/certificate.go @@ -12,6 +12,8 @@ import ( type certificateService interface { ArchiveFile(ctx context.Context, req *dtos.CertificateArchiveFileReq) ([]byte, error) + ValidateCertificate(ctx context.Context, req *dtos.CertificateValidateCertificateReq) (*dtos.CertificateValidateCertificateResp, error) + ValidatePrivateKey(ctx context.Context, req *dtos.CertificateValidatePrivateKeyReq) error } type CertificateHandler struct { @@ -25,6 +27,32 @@ func NewCertificateHandler(router *router.RouterGroup[*core.RequestEvent], servi group := router.Group("/certificates") group.POST("/{id}/archive", handler.run) + group.POST("/validate/certificate", handler.validateCertificate) + group.POST("/validate/private-key", handler.validatePrivateKey) +} + +func (handler *CertificateHandler) validateCertificate(e *core.RequestEvent) error { + req := &dtos.CertificateValidateCertificateReq{} + if err := e.BindBody(req); err != nil { + return resp.Err(e, err) + } + if rs, err := handler.service.ValidateCertificate(e.Request.Context(), req); err != nil { + return resp.Err(e, err) + } else { + return resp.Ok(e, rs) + } +} + +func (handler *CertificateHandler) validatePrivateKey(e *core.RequestEvent) error { + req := &dtos.CertificateValidatePrivateKeyReq{} + if err := e.BindBody(req); err != nil { + return resp.Err(e, err) + } + if err := handler.service.ValidatePrivateKey(e.Request.Context(), req); err != nil { + return resp.Err(e, err) + } else { + return resp.Ok(e, nil) + } } func (handler *CertificateHandler) run(e *core.RequestEvent) error { diff --git a/internal/workflow/node-processor/processor.go b/internal/workflow/node-processor/processor.go index 3c347815..55e8477a 100644 --- a/internal/workflow/node-processor/processor.go +++ b/internal/workflow/node-processor/processor.go @@ -66,6 +66,8 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) { return NewConditionNode(node), nil case domain.WorkflowNodeTypeApply: return NewApplyNode(node), nil + case domain.WorkflowNodeTypeUpload: + return NewUploadNode(node), nil case domain.WorkflowNodeTypeDeploy: return NewDeployNode(node), nil case domain.WorkflowNodeTypeNotify: diff --git a/internal/workflow/node-processor/upload_node.go b/internal/workflow/node-processor/upload_node.go new file mode 100644 index 00000000..8aa0bba7 --- /dev/null +++ b/internal/workflow/node-processor/upload_node.go @@ -0,0 +1,101 @@ +package nodeprocessor + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/usual2970/certimate/internal/domain" + "github.com/usual2970/certimate/internal/pkg/utils/certs" + "github.com/usual2970/certimate/internal/repository" +) + +type uploadNode struct { + node *domain.WorkflowNode + outputRepo workflowOutputRepository + *nodeLogger +} + +func NewUploadNode(node *domain.WorkflowNode) *uploadNode { + return &uploadNode{ + node: node, + nodeLogger: NewNodeLogger(node), + outputRepo: repository.NewWorkflowOutputRepository(), + } +} + +// Run 上传证书节点执行 +// 包含上传证书的工作流,理论上应该手动执行,如果每天定时执行,也只是重新保存一下 +func (n *uploadNode) Run(ctx context.Context) error { + n.AddOutput(ctx, + n.node.Name, + "进入上传证书节点", + ) + + config := n.node.GetConfigForUpload() + + // 检查证书是否过期 + // 如果证书过期,则直接返回错误 + certX509, err := certs.ParseCertificateFromPEM(config.Certificate) + if err != nil { + n.AddOutput(ctx, + n.node.Name, + "解析证书失败", + ) + return err + } + + if time.Now().After(certX509.NotAfter) { + n.AddOutput(ctx, + n.node.Name, + "证书已过期", + ) + return errors.New("certificate is expired") + } + + certificate := &domain.Certificate{ + Source: domain.CertificateSourceTypeUpload, + SubjectAltNames: strings.Join(certX509.DNSNames, ";"), + Certificate: config.Certificate, + PrivateKey: config.PrivateKey, + + EffectAt: certX509.NotBefore, + ExpireAt: certX509.NotAfter, + WorkflowId: getContextWorkflowId(ctx), + WorkflowNodeId: n.node.Id, + } + + // 保存执行结果 + // TODO: 先保持一个节点始终只有一个输出,后续增加版本控制 + currentOutput := &domain.WorkflowOutput{ + WorkflowId: getContextWorkflowId(ctx), + NodeId: n.node.Id, + Node: n.node, + Succeeded: true, + Outputs: n.node.Outputs, + } + + // 查询上次执行结果 + lastOutput, err := n.outputRepo.GetByNodeId(ctx, n.node.Id) + if err != nil && !domain.IsRecordNotFoundError(err) { + n.AddOutput(ctx, n.node.Name, "查询上传记录失败", err.Error()) + return err + } + if lastOutput != nil { + currentOutput.Id = lastOutput.Id + } + if err := n.outputRepo.Save(ctx, currentOutput, certificate, func(id string) error { + if certificate != nil { + certificate.WorkflowOutputId = id + } + + return nil + }); err != nil { + n.AddOutput(ctx, n.node.Name, "保存上传记录失败", err.Error()) + return err + } + n.AddOutput(ctx, n.node.Name, "保存上传记录成功") + + return nil +} diff --git a/ui/src/api/certificates.ts b/ui/src/api/certificates.ts index b8764d0a..2a839b5a 100644 --- a/ui/src/api/certificates.ts +++ b/ui/src/api/certificates.ts @@ -22,3 +22,41 @@ export const archive = async (id: string, format?: CertificateFormatType) => { return resp; }; + +type ValidateCertificateResp = { + domains: string; +}; + +export const validateCertificate = async (certificate: string) => { + const pb = getPocketBase(); + const resp = await pb.send>(`/api/certificates/validate/certificate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + certificate: certificate, + }, + }); + if (resp.code != 0) { + throw new Error(resp.msg); + } + return resp; +}; + +export const validatePrivateKey = async (privateKey: string) => { + const pb = getPocketBase(); + const resp = await pb.send(`/api/certificates/validate/private-key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: { + privateKey: privateKey, + }, + }); + if (resp.code != 0) { + throw new Error(resp.msg); + } + return resp; +}; diff --git a/ui/src/components/workflow/WorkflowElement.tsx b/ui/src/components/workflow/WorkflowElement.tsx index 289689dd..d2272ef0 100644 --- a/ui/src/components/workflow/WorkflowElement.tsx +++ b/ui/src/components/workflow/WorkflowElement.tsx @@ -11,6 +11,7 @@ import ExecuteResultBranchNode from "./node/ExecuteResultBranchNode"; import ExecuteResultNode from "./node/ExecuteResultNode"; import NotifyNode from "./node/NotifyNode"; import StartNode from "./node/StartNode"; +import UploadNode from "./node/UploadNode"; export type WorkflowElementProps = { node: WorkflowNode; @@ -28,6 +29,9 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem case WorkflowNodeType.Apply: return ; + case WorkflowNodeType.Upload: + return ; + case WorkflowNodeType.Deploy: return ; @@ -60,3 +64,4 @@ const WorkflowElement = ({ node, disabled, branchId, branchIndex }: WorkflowElem }; export default memo(WorkflowElement); + diff --git a/ui/src/components/workflow/node/AddNode.tsx b/ui/src/components/workflow/node/AddNode.tsx index a4fa451f..49c8c4d2 100644 --- a/ui/src/components/workflow/node/AddNode.tsx +++ b/ui/src/components/workflow/node/AddNode.tsx @@ -6,6 +6,7 @@ import { SendOutlined as SendOutlinedIcon, SisternodeOutlined as SisternodeOutlinedIcon, SolutionOutlined as SolutionOutlinedIcon, + SafetyOutlined as SafetyOutlinedIcon, } from "@ant-design/icons"; import { Dropdown } from "antd"; @@ -25,6 +26,7 @@ const AddNode = ({ node, disabled }: AddNodeProps) => { const dropdownMenus = useMemo(() => { return [ [WorkflowNodeType.Apply, "workflow_node.apply.label", ], + [WorkflowNodeType.Upload, "workflow_node.upload.label", ], [WorkflowNodeType.Deploy, "workflow_node.deploy.label", ], [WorkflowNodeType.Branch, "workflow_node.branch.label", ], [WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", ], diff --git a/ui/src/components/workflow/node/UploadNode.tsx b/ui/src/components/workflow/node/UploadNode.tsx new file mode 100644 index 00000000..3d0e20c3 --- /dev/null +++ b/ui/src/components/workflow/node/UploadNode.tsx @@ -0,0 +1,91 @@ +import { memo, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Flex, Typography } from "antd"; +import { produce } from "immer"; + +import type { WorkflowNodeConfigForUpload } from "@/domain/workflow"; +import { WorkflowNodeType } from "@/domain/workflow"; +import { useZustandShallowSelector } from "@/hooks"; +import { useWorkflowStore } from "@/stores/workflow"; + +import UploadNodeConfigForm, { type UploadNodeConfigFormInstance } from "./UploadNodeConfigForm"; +import SharedNode, { type SharedNodeProps } from "./_SharedNode"; + +export type UploadNodeProps = SharedNodeProps; + +const UploadNode = ({ node, disabled }: UploadNodeProps) => { + if (node.type !== WorkflowNodeType.Upload) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Upload}`); + } + + const { t } = useTranslation(); + + const { updateNode } = useWorkflowStore(useZustandShallowSelector(["updateNode"])); + + const formRef = useRef(null); + const [formPending, setFormPending] = useState(false); + + const [drawerOpen, setDrawerOpen] = useState(false); + const getFormValues = () => formRef.current!.getFieldsValue() as WorkflowNodeConfigForUpload; + + const wrappedEl = useMemo(() => { + if (node.type !== WorkflowNodeType.Upload) { + console.warn(`[certimate] current workflow node type is not: ${WorkflowNodeType.Upload}`); + } + + if (!node.validated) { + return {t("workflow_node.action.configure_node")}; + } + + const config = (node.config as WorkflowNodeConfigForUpload) ?? {}; + return ( + + {config.domains ?? ""} + + ); + }, [node]); + + const handleDrawerConfirm = async () => { + setFormPending(true); + try { + await formRef.current!.validateFields(); + } catch (err) { + setFormPending(false); + throw err; + } + + try { + const newValues = getFormValues(); + const newNode = produce(node, (draft) => { + draft.config = { + ...newValues, + }; + draft.validated = true; + }); + await updateNode(newNode); + } finally { + setFormPending(false); + } + }; + + return ( + <> + setDrawerOpen(true)}> + {wrappedEl} + + + setDrawerOpen(open)} + getFormValues={() => formRef.current!.getFieldsValue()} + > + + + + ); +}; + +export default memo(UploadNode); diff --git a/ui/src/components/workflow/node/UploadNodeConfigForm.tsx b/ui/src/components/workflow/node/UploadNodeConfigForm.tsx new file mode 100644 index 00000000..c308135b --- /dev/null +++ b/ui/src/components/workflow/node/UploadNodeConfigForm.tsx @@ -0,0 +1,179 @@ +import { forwardRef, memo, useEffect, useImperativeHandle } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Form, type FormInstance, Input, Upload, type UploadProps } from "antd"; +import { createSchemaFieldRule } from "antd-zod"; +import { z } from "zod"; + +import { validateCertificate, validatePrivateKey } from "@/api/certificates"; +import { type WorkflowNodeConfigForUpload } from "@/domain/workflow"; +import { useAntdForm } from "@/hooks"; +import { getErrMsg } from "@/utils/error"; +import { readFileContent } from "@/utils/file"; + +type UploadNodeConfigFormFieldValues = Partial; + +export type UploadNodeConfigFormProps = { + className?: string; + style?: React.CSSProperties; + disabled?: boolean; + initialValues?: UploadNodeConfigFormFieldValues; + onValuesChange?: (values: UploadNodeConfigFormFieldValues) => void; +}; + +export type UploadNodeConfigFormInstance = { + getFieldsValue: () => ReturnType["getFieldsValue"]>; + resetFields: FormInstance["resetFields"]; + validateFields: FormInstance["validateFields"]; +}; + +const initFormModel = (): UploadNodeConfigFormFieldValues => { + return {}; +}; + +const UploadNodeConfigForm = forwardRef( + ({ className, style, disabled, initialValues, onValuesChange }, ref) => { + const { t } = useTranslation(); + + const formSchema = z.object({ + certificateId: z.string().optional(), + domains: z.string().optional(), + certificate: z + .string({ message: t("workflow_node.upload.form.certificate.placeholder") }) + .min(1, t("workflow_node.upload.form.certificate.placeholder")) + .max(5120, t("common.errmsg.string_max", { max: 5120 })), + privateKey: z + .string({ message: t("workflow_node.upload.form.private_key.placeholder") }) + .min(1, t("workflow_node.upload.form.private_key.placeholder")) + .max(5120, t("common.errmsg.string_max", { max: 5120 })), + }); + const formRule = createSchemaFieldRule(formSchema); + const { form: formInst, formProps } = useAntdForm({ + name: "workflowNodeUploadConfigForm", + initialValues: initialValues ?? initFormModel(), + }); + + const certificate = Form.useWatch("certificate", formInst); + const privateKey = Form.useWatch("privateKey", formInst); + + useEffect(() => { + if (certificate && privateKey) { + formInst.validateFields(["certificate", "privateKey"]); + } + }, [certificate, privateKey]); + + const handleFormChange = (_: unknown, values: z.infer) => { + onValuesChange?.(values as UploadNodeConfigFormFieldValues); + }; + + useImperativeHandle(ref, () => { + return { + getFieldsValue: () => { + return formInst.getFieldsValue(true); + }, + resetFields: (fields) => { + return formInst.resetFields(fields as (keyof UploadNodeConfigFormFieldValues)[]); + }, + validateFields: (nameList, config) => { + return formInst.validateFields(nameList, config); + }, + } as UploadNodeConfigFormInstance; + }); + + const handleCertificateFileChange: UploadProps["onChange"] = async ({ file }) => { + if (file && file.status !== "removed") { + const certificate = await readFileContent(file.originFileObj ?? (file as unknown as File)); + + try { + const resp = await validateCertificate(certificate); + formInst.setFields([ + { + name: "certificate", + value: certificate, + errors: [], + }, + { + name: "domains", + value: resp.data.domains, + }, + ]); + } catch (e) { + formInst.setFields([ + { + name: "certificate", + value: "", + errors: [getErrMsg(e)], + }, + ]); + } + } else { + formInst.setFieldValue("certificate", ""); + } + onValuesChange?.(formInst.getFieldsValue(true)); + }; + + const handlePrivateKeyFileChange: UploadProps["onChange"] = async ({ file }) => { + if (file && file.status !== "removed") { + const privateKey = await readFileContent(file.originFileObj ?? (file as unknown as File)); + try { + await validatePrivateKey(privateKey); + formInst.setFields([ + { + name: "privateKey", + value: privateKey, + errors: [], + }, + ]); + } catch (e) { + formInst.setFields([ + { + name: "privateKey", + errors: [getErrMsg(e)], + }, + ]); + } + } else { + formInst.setFieldValue("privateKey", ""); + } + + onValuesChange?.(formInst.getFieldsValue(true)); + }; + + return ( +
+ + + + + + +
+ false} maxCount={1} onChange={handleCertificateFileChange}> + + +
+
+ + + +
+ false} maxCount={1} onChange={handlePrivateKeyFileChange}> + + +
+
+
+ ); + } +); + +export default memo(UploadNodeConfigForm); diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts index 6d35d578..b1296d9d 100644 --- a/ui/src/domain/workflow.ts +++ b/ui/src/domain/workflow.ts @@ -35,6 +35,7 @@ export enum WorkflowNodeType { ExecuteFailure = "execute_failure", Condition = "condition", Apply = "apply", + Upload = "upload", Deploy = "deploy", Notify = "notify", Custom = "custom", @@ -49,6 +50,7 @@ const workflowNodeTypeDefaultNames: Map = new Map([ [WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.label")], [WorkflowNodeType.Condition, i18n.t("workflow_node.condition.label")], [WorkflowNodeType.Apply, i18n.t("workflow_node.apply.label")], + [WorkflowNodeType.Upload, i18n.t("workflow_node.upload.label")], [WorkflowNodeType.Deploy, i18n.t("workflow_node.deploy.label")], [WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")], [WorkflowNodeType.Custom, i18n.t("workflow_node.custom.title")], @@ -82,6 +84,17 @@ const workflowNodeTypeDefaultOutputs: Map = }, ], ], + [ + WorkflowNodeType.Upload, + [ + { + name: "certificate", + type: "certificate", + required: true, + label: "证书", + }, + ], + ], [WorkflowNodeType.Deploy, []], [WorkflowNodeType.Notify, []], ]); @@ -121,6 +134,13 @@ export type WorkflowNodeConfigForApply = { skipBeforeExpiryDays: number; }; +export type WorkflowNodeConfigForUpload = { + certificateId: string; + domains: string; + certificate: string; + privateKey: string; +}; + export type WorkflowNodeConfigForDeploy = { certificate: string; provider: string; @@ -202,6 +222,7 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} switch (nodeType) { case WorkflowNodeType.Apply: + case WorkflowNodeType.Upload: case WorkflowNodeType.Deploy: { node.inputs = workflowNodeTypeDefaultInputs.get(nodeType); @@ -220,11 +241,13 @@ export const newNode = (nodeType: WorkflowNodeType, options: NewNodeOptions = {} node.branches = [newNode(WorkflowNodeType.Condition, { branchIndex: 0 }), newNode(WorkflowNodeType.Condition, { branchIndex: 1 })]; } break; + case WorkflowNodeType.ExecuteResultBranch: { node.branches = [newNode(WorkflowNodeType.ExecuteSuccess), newNode(WorkflowNodeType.ExecuteFailure)]; } break; + case WorkflowNodeType.ExecuteSuccess: case WorkflowNodeType.ExecuteFailure: { diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json index f6def82c..87207597 100644 --- a/ui/src/i18n/locales/en/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json @@ -377,6 +377,15 @@ "workflow_node.notify.form.channel.placeholder": "Please select channel", "workflow_node.notify.form.channel.button": "Configure", + "workflow_node.upload.label": "Upload certificate", + "workflow_node.upload.form.domains.label": "Domains", + "workflow_node.upload.form.certificate.label": "Certificate", + "workflow_node.upload.form.certificate.placeholder": "The certificate format begins with \"-----BEGIN CERTIFICATE-----\" and ends with \"-----END CERTIFICATE-----\"", + "workflow_node.upload.form.certificate.button": "Upload", + "workflow_node.upload.form.private_key.label": "Private key", + "workflow_node.upload.form.private_key.placeholder": "The private key begins with \"-----BEGIN (RSA|EC) PRIVATE KEY-----\" and ends with \"-----END(RSA|EC) PRIVATE KEY-----\"", + "workflow_node.upload.form.private_key.button": "Upload", + "workflow_node.end.label": "End", "workflow_node.branch.label": "Branch", @@ -389,3 +398,4 @@ "workflow_node.condition.label": "Condition" } + diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json index aa82404e..609e96ee 100644 --- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json +++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json @@ -377,6 +377,15 @@ "workflow_node.notify.form.channel.placeholder": "请选择通知渠道", "workflow_node.notify.form.channel.button": "去配置", + "workflow_node.upload.label": "上传证书", + "workflow_node.upload.form.domains.label": "证书域名", + "workflow_node.upload.form.certificate.label": "证书文件", + "workflow_node.upload.form.certificate.placeholder": "证书格式以\"-----BEGIN CERTIFICATE-----\"开头,以\"-----END CERTIFICATE-----\"结尾。", + "workflow_node.upload.form.certificate.button": "上传", + "workflow_node.upload.form.private_key.label": "证书私钥", + "workflow_node.upload.form.private_key.placeholder": "证书私钥格式以\"-----BEGIN (RSA|EC) PRIVATE KEY-----\"开头,以\"-----END(RSA|EC) PRIVATE KEY-----\"结尾。", + "workflow_node.upload.form.private_key.button": "上传", + "workflow_node.end.label": "结束", "workflow_node.branch.label": "分支", @@ -389,3 +398,4 @@ "workflow_node.condition.label": "条件" } +