From cf8abb45282070c8ba91469f93fd379fabf1f74a Mon Sep 17 00:00:00 2001 From: xiaojunnuo Date: Tue, 15 Apr 2025 23:43:01 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E6=8F=92=E4=BB=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plugins/plugin-lib/src/ssh/ssh-access.ts | 6 +- packages/plugins/plugin-lib/src/ssh/ssh.ts | 46 ++++-- .../certd-client/src/views/sys/plugin/api.ts | 16 ++ .../src/views/sys/plugin/crud.tsx | 124 +++++++++++++++- packages/ui/certd-server/src/configuration.ts | 5 + .../sys/plugin/plugin-controller.ts | 15 +- .../plugin/service/plugin-config-service.ts | 2 + .../modules/plugin/service/plugin-service.ts | 137 +++++++++++++++--- 8 files changed, 306 insertions(+), 45 deletions(-) diff --git a/packages/plugins/plugin-lib/src/ssh/ssh-access.ts b/packages/plugins/plugin-lib/src/ssh/ssh-access.ts index 0c759de1..9e4786e3 100644 --- a/packages/plugins/plugin-lib/src/ssh/ssh-access.ts +++ b/packages/plugins/plugin-lib/src/ssh/ssh-access.ts @@ -1,7 +1,4 @@ import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline"; -import { ConnectConfig } from "ssh2"; -import { SshClient } from "./ssh.js"; - @IsAccess({ name: "ssh", title: "主机登录授权", @@ -9,7 +6,7 @@ import { SshClient } from "./ssh.js"; icon: "clarity:host-line", input: {}, }) -export class SshAccess extends BaseAccess implements ConnectConfig { +export class SshAccess extends BaseAccess { @AccessInput({ title: "主机地址", component: { @@ -125,6 +122,7 @@ export class SshAccess extends BaseAccess implements ConnectConfig { testRequest = true; async onTestRequest() { + const { SshClient } = await import("./ssh.js"); const client = new SshClient(this.ctx.logger); await client.exec({ diff --git a/packages/plugins/plugin-lib/src/ssh/ssh.ts b/packages/plugins/plugin-lib/src/ssh/ssh.ts index 41bb4049..a86b7464 100644 --- a/packages/plugins/plugin-lib/src/ssh/ssh.ts +++ b/packages/plugins/plugin-lib/src/ssh/ssh.ts @@ -1,22 +1,33 @@ // @ts-ignore -import ssh2, { ConnectConfig, ExecOptions } from "ssh2"; - -import ssh2Constants from "ssh2/lib/protocol/constants.js"; import path from "path"; -import * as _ from "lodash-es"; +import { isArray } from "lodash-es"; import { ILogger } from "@certd/basic"; import { SshAccess } from "./ssh-access.js"; -import stripAnsi from "strip-ansi"; -import { SocksClient } from "socks"; -import { SocksProxy, SocksProxyType } from "socks/typings/common/constants.js"; + import fs from "fs"; +import { SocksProxyType } from "socks/typings/common/constants"; export type TransportItem = { localPath: string; remotePath: string }; +export interface SocksProxy { + ipaddress?: string; + host?: string; + port: number; + type: any; + userId?: string; + password?: string; + custom_auth_method?: number; + custom_auth_request_handler?: () => Promise; + custom_auth_response_size?: number; + custom_auth_response_handler?: (data: Buffer) => Promise; +} +export type SshConnectConfig = { + sock?: any; +}; export class AsyncSsh2Client { - conn: ssh2.Client; + conn: any; logger: ILogger; - connConf: SshAccess & ssh2.ConnectConfig; + connConf: SshAccess & SshConnectConfig; windows = false; encoding: string; constructor(connConf: SshAccess, logger: ILogger) { @@ -40,7 +51,10 @@ export class AsyncSsh2Client { if (typeof this.connConf.port === "string") { this.connConf.port = parseInt(this.connConf.port); } - const proxyOption: SocksProxy = this.parseSocksProxyFromUri(this.connConf.socksProxy); + + const { SocksClient } = await import("socks"); + + const proxyOption = this.parseSocksProxyFromUri(this.connConf.socksProxy); const info = await SocksClient.createConnection({ proxy: proxyOption, command: "connect", @@ -53,10 +67,12 @@ export class AsyncSsh2Client { this.connConf.sock = info.socket; } - const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants; + const ssh2 = await import("ssh2"); + const ssh2Constants = await import("ssh2/lib/protocol/constants.js"); + const { SUPPORTED_KEX, SUPPORTED_SERVER_HOST_KEY, SUPPORTED_CIPHER, SUPPORTED_MAC } = ssh2Constants.default; return new Promise((resolve, reject) => { try { - const conn = new ssh2.Client(); + const conn = new ssh2.default.Client(); conn .on("error", (err: any) => { this.logger.error("连接失败", err); @@ -197,6 +213,8 @@ export class AsyncSsh2Client { } async shell(script: string | string[]): Promise { + const stripAnsiModule = await import("strip-ansi"); + const stripAnsi = stripAnsiModule.default; return new Promise((resolve, reject) => { this.logger.info(`执行shell脚本:[${this.connConf.host}][shell]: ` + script); this.conn.shell((err: Error, stream: any) => { @@ -449,7 +467,7 @@ export class SshClient { script = script.join(" && "); } else { const newLine = isLinux ? "\n" : "\r\n"; - if (_.isArray(script)) { + if (isArray(script)) { script = script as Array; script = script.join(newLine); } @@ -465,7 +483,7 @@ export class SshClient { async shell(options: { connectConf: SshAccess; script: string | Array }): Promise { let { script } = options; const { connectConf } = options; - if (_.isArray(script)) { + if (isArray(script)) { script = script as Array; if (connectConf.windows) { script = script.join("\r\n"); diff --git a/packages/ui/certd-client/src/views/sys/plugin/api.ts b/packages/ui/certd-client/src/views/sys/plugin/api.ts index dd5800f4..f31a2b4c 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/api.ts +++ b/packages/ui/certd-client/src/views/sys/plugin/api.ts @@ -66,6 +66,22 @@ export async function SetDisabled(data: { id?: number; name?: string; type?: str }); } +export async function ExportPlugin(id: number) { + return await request({ + url: apiPrefix + "/export", + method: "post", + data: { id }, + }); +} + +export async function ImportPlugin(body: any) { + return await request({ + url: apiPrefix + "/import", + method: "post", + data: body, + }); +} + export type PluginConfigBean = { name: string; disabled: boolean; diff --git a/packages/ui/certd-client/src/views/sys/plugin/crud.tsx b/packages/ui/certd-client/src/views/sys/plugin/crud.tsx index fa91e6ee..1c8eac4d 100644 --- a/packages/ui/certd-client/src/views/sys/plugin/crud.tsx +++ b/packages/ui/certd-client/src/views/sys/plugin/crud.tsx @@ -2,8 +2,8 @@ import * as api from "./api"; import { useI18n } from "vue-i18n"; import { Ref, ref } from "vue"; import { useRouter } from "vue-router"; -import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; -import { Modal } from "ant-design-vue"; +import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, useFormWrapper, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; +import { Modal, notification } from "ant-design-vue"; //@ts-ignore import yaml from "js-yaml"; @@ -36,7 +36,74 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat const selectedRowKeys: Ref = ref([]); context.selectedRowKeys = selectedRowKeys; + const { openCrudFormDialog } = useFormWrapper(); + async function openImportDialog() { + function createCrudOptions() { + return { + crudOptions: { + columns: { + content: { + title: "插件文件", + type: "text", + form: { + component: { + name: "pem-input", + vModel: "modelValue", + textarea: { + rows: 8, + }, + }, + col: { + span: 24, + }, + helper: "选择插件文件", + }, + }, + override: { + title: "同名覆盖", + type: "dict-switch", + dict: dict({ + data: [ + { + value: true, + label: "覆盖", + }, + { + value: false, + label: "不覆盖", + }, + ], + }), + form: { + value: false, + col: { + span: 24, + }, + helper: "如果已有相同名称插件,直接覆盖", + }, + }, + }, + form: { + wrapper: { + title: "导入插件", + saveRemind: false, + }, + afterSubmit() { + notification.success({ message: "操作成功" }); + }, + async doSubmit({ form }: any) { + return await api.ImportPlugin({ + ...form, + }); + }, + }, + }, + }; + } + const { crudOptions } = createCrudOptions(); + await openCrudFormDialog({ crudOptions }); + } return { crudOptions: { settings: { @@ -65,8 +132,18 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat buttons: { add: { show: true, + icon: "ion:ios-add-circle-outline", text: "自定义插件", }, + import: { + show: true, + icon: "ion:cloud-upload-outline", + text: "导入", + type: "primary", + async click() { + await openImportDialog(); + }, + }, }, }, rowHandle: { @@ -85,10 +162,33 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat }), }, remove: { + order: 999, show: compute(({ row }) => { return row.type === "custom"; }), }, + export: { + text: null, + icon: "ion:cloud-download-outline", + title: "导出", + type: "link", + show: compute(({ row }) => { + return row.type === "custom"; + }), + async click({ row }) { + //将文本内容,作为文件下载 + const content = await api.ExportPlugin(row.id); + if (content) { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${row.name}.yaml`; + link.click(); + URL.revokeObjectURL(url); + } + }, + }, }, }, table: { @@ -182,8 +282,15 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat }, form: { show: true, - helper: "必须为英文,驼峰命名,类型作为前缀\n例如AliyunDeployToCDN\n插件一旦被使用,不要修改名称", - rules: [{ required: true }], + helper: "必须为英文或数字,驼峰命名,类型作为前缀\n例如AliyunDeployToCDN\n插件一旦被使用,不要修改名称", + rules: [ + { required: true }, + { + type: "regexp", + pattern: /^[a-zA-Z][a-zA-Z0-9]+$/, + message: "必须为英文或数字,驼峰命名,类型作为前缀", + }, + ], }, column: { width: 250, @@ -205,7 +312,14 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat form: { show: true, helper: "上传到插件商店时,将作为插件名称前缀,例如:greper/pluginName", - rules: [{ required: true }], + rules: [ + { required: true }, + { + type: "regexp", + pattern: /^[a-zA-Z][a-zA-Z0-9]+$/, + message: "必须为英文字母或数字", + }, + ], }, column: { width: 200, diff --git a/packages/ui/certd-server/src/configuration.ts b/packages/ui/certd-server/src/configuration.ts index bc27b11f..ebf6aa08 100644 --- a/packages/ui/certd-server/src/configuration.ts +++ b/packages/ui/certd-server/src/configuration.ts @@ -26,6 +26,11 @@ process.on('uncaughtException', error => { }); @Configuration({ + // detectorOptions: { + // ignore: [ + // '**/plugins/**' + // ] + // }, imports: [ koa, orm, diff --git a/packages/ui/certd-server/src/controller/sys/plugin/plugin-controller.ts b/packages/ui/certd-server/src/controller/sys/plugin/plugin-controller.ts index bbf9103c..a0bd13f4 100644 --- a/packages/ui/certd-server/src/controller/sys/plugin/plugin-controller.ts +++ b/packages/ui/certd-server/src/controller/sys/plugin/plugin-controller.ts @@ -1,7 +1,7 @@ import { ALL, Body, Controller, Inject, Post, Provide, Query } from '@midwayjs/core'; import { merge } from 'lodash-es'; import { CrudController } from '@certd/lib-server'; -import { PluginService } from '../../../modules/plugin/service/plugin-service.js'; +import { PluginImportReq, PluginService } from "../../../modules/plugin/service/plugin-service.js"; import { CommPluginConfig, PluginConfigService } from '../../../modules/plugin/service/plugin-config-service.js'; /** * 插件 @@ -82,4 +82,17 @@ export class PluginController extends CrudController { const res = await this.pluginConfigService.saveCommPluginConfig(body); return this.ok(res); } + + + @Post('/import', { summary: 'sys:settings:edit' }) + async import(@Body(ALL) body: PluginImportReq) { + const res = await this.service.importPlugin(body); + return this.ok(res); + } + + @Post('/export', { summary: 'sys:settings:edit' }) + async export(@Body('id') id: number) { + const res = await this.service.exportPlugin(id); + return this.ok(res); + } } diff --git a/packages/ui/certd-server/src/modules/plugin/service/plugin-config-service.ts b/packages/ui/certd-server/src/modules/plugin/service/plugin-config-service.ts index cd54f556..d7c597ca 100644 --- a/packages/ui/certd-server/src/modules/plugin/service/plugin-config-service.ts +++ b/packages/ui/certd-server/src/modules/plugin/service/plugin-config-service.ts @@ -18,6 +18,8 @@ export type PluginFindReq = { name?: string; type: string; }; + + @Provide() @Scope(ScopeEnum.Request, { allowDowngrade: true }) export class PluginConfigService { diff --git a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts index 78aede1a..67b6c62e 100644 --- a/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts +++ b/packages/ui/certd-server/src/modules/plugin/service/plugin-service.ts @@ -12,6 +12,10 @@ import { logger } from "@certd/basic"; import yaml from "js-yaml"; import { getDefaultAccessPlugin, getDefaultDeployPlugin, getDefaultDnsPlugin } from "./default-plugin.js"; +export type PluginImportReq = { + content: string, + override?: boolean; +}; @Provide() @Scope(ScopeEnum.Request, { allowDowngrade: true }) @@ -41,7 +45,7 @@ export class PluginService extends BaseService { const builtInList = await this.getBuiltInEntityList(); //获取分页数据 - const data = builtInList.slice(offset, offset + limit); + const data = builtInList.slice(offset, offset + limit); return { records: data, @@ -53,7 +57,7 @@ export class PluginService extends BaseService { async getEnabledBuildInGroup(isSimple = false) { const groups = this.builtInPluginService.getGroups(); - if(isSimple){ + if (isSimple) { for (const key in groups) { const group = groups[key]; group.plugins.forEach(item => { @@ -97,8 +101,8 @@ export class PluginService extends BaseService { }); const disabledNames = list.map(it => it.name); - return builtInList.filter(it =>{ - return !disabledNames.includes(it.name) + return builtInList.filter(it => { + return !disabledNames.includes(it.name); }); } @@ -168,33 +172,48 @@ export class PluginService extends BaseService { name: param.name, author: param.author } - }) + }); if (old) { throw new Error(`插件${param.author}/${param.name}已存在`); } - let plugin:any = {} + let plugin: any = {}; if (param.pluginType === "access") { - plugin = getDefaultAccessPlugin() - delete param.group - }else if (param.pluginType === "deploy") { - plugin = getDefaultDeployPlugin() - }else if (param.pluginType === "dnsProvider") { - plugin = getDefaultDnsPlugin() - delete param.group - }else{ + plugin = getDefaultAccessPlugin(); + delete param.group; + } else if (param.pluginType === "deploy") { + plugin = getDefaultDeployPlugin(); + } else if (param.pluginType === "dnsProvider") { + plugin = getDefaultDnsPlugin(); + delete param.group; + } else { throw new Error(`插件类型${param.pluginType}不支持`); } - return await super.add({ + return await super.add({ ...param, ...plugin }); } + async update(param: any) { + const old = await this.repository.findOne({ + where: { + name: param.name, + author: param.author + } + }); + + if (old && old.id !== param.id) { + throw new Error(`插件${param.author}/${param.name}已存在`); + } + + return await super.update(param); + } + async compile(code: string) { - const ts = await import("typescript") + const ts = await import("typescript"); return ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.ESNext } }).outputText; @@ -220,16 +239,16 @@ export class PluginService extends BaseService { if (info && info.length > 0) { const plugin = info[0]; - try{ + try { const AsyncFunction = Object.getPrototypeOf(async () => { }).constructor; // const script = await this.compile(plugin.content); - const script = plugin.content + const script = plugin.content; const getPluginClass = new AsyncFunction(script); return await getPluginClass({ logger: logger }); - }catch (e) { - logger.error("编译插件失败:",e) - throw e + } catch (e) { + logger.error("编译插件失败:", e); + throw e; } } @@ -284,4 +303,80 @@ export class PluginService extends BaseService { }); } + async exportPlugin(id: number) { + const info = await this.info(id); + if (!info) { + throw new Error("插件不存在"); + } + const metadata = yaml.load(info.metadata || ""); + const extra = yaml.load(info.extra || ""); + const content = info.content; + delete info.metadata; + delete info.extra; + delete info.content; + delete info.id; + delete info.createTime; + delete info.updateTime; + const plugin = { + ...info, + ...metadata, + ...extra, + content + }; + + return yaml.dump(plugin) as string; + } + + async importPlugin(req: PluginImportReq) { + + const loaded = yaml.load(req.content); + if (!loaded) { + throw new Error("插件内容不能为空"); + } + delete loaded.id + + const old = await this.repository.findOne({ + where: { + name: loaded.name, + author: loaded.author + } + }); + + const metadata = { + input: loaded.input, + output: loaded.output + }; + const extra = { + dependPlugins: loaded.dependPlugins, + default: loaded.default, + showRunStrategy: loaded.showRunStrategy + }; + + const pluginEntity = { + ...loaded, + metadata: yaml.dump(metadata), + extra: yaml.dump(extra), + content: req.content, + disabled: false + }; + if (!pluginEntity.pluginType) { + throw new Error(`插件类型不能为空`); + } + + if (old) { + if (!req.override) { + throw new Error(`插件${loaded.author}/${loaded.name}已存在`); + } + //update + pluginEntity.id = old.id; + await this.update(pluginEntity); + } else { + //add + const { id } = await this.add(pluginEntity); + pluginEntity.id = id; + } + return { + id: pluginEntity.id + }; + } }