diff --git a/server/node-service/package.json b/server/node-service/package.json index c6378705a..58d167ea5 100644 --- a/server/node-service/package.json +++ b/server/node-service/package.json @@ -16,6 +16,7 @@ "build": "rm -rf build/ && yarn test && tsc && yarn copy" }, "devDependencies": { + "@types/ali-oss": "^6.16.11", "@types/jest": "^29.2.4", "commander": "^10.0.0", "copyfiles": "^2.4.1", @@ -46,6 +47,7 @@ "@types/morgan": "^1.9.3", "@types/node": "^20.1.1", "@types/node-fetch": "^2.6.2", + "ali-oss": "^6.20.0", "axios": "^1.7.7", "base64-arraybuffer": "^1.0.2", "bluebird": "^3.7.2", diff --git a/server/node-service/src/plugins/aliyunOss/dataSourceConfig.ts b/server/node-service/src/plugins/aliyunOss/dataSourceConfig.ts new file mode 100644 index 000000000..e58754335 --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/dataSourceConfig.ts @@ -0,0 +1,52 @@ +import { ConfigToType } from "lowcoder-sdk/dataSource"; +import { AliyunOssI18nTranslator } from "./i18n"; + +const getDataSourceConfig = (i18n: AliyunOssI18nTranslator) => { + const dataSourceConfig = { + type: "dataSource", + params: [ + { + key: "accessKeyId", + label: "Access key ID", + type: "textInput", + placeholder: "", + rules: [{ required: true, message: i18n.trans("akRequiredMessage") }], + }, + { + key: "accessKeySecret", + label: "Secret key", + type: "password", + placeholder: "", + rules: [{ required: true, message: i18n.trans("skRequiredMessage") }], + }, + { + key: "arn", + label: "ARN", + type: "password", + tooltip: i18n.trans("arnTooltip"), + placeholder: "", + rules: [{ required: true, message: i18n.trans("arnRequiredMessage") }], + }, + { + key: "endpointUrl", + label: "STS Endpoint", + type: "textInput", + tooltip: i18n.trans("endpointUrlTooltip"), + default: "sts.cn-hangzhou.aliyuncs.com", + rules: [{ required: true }], + }, + { + key: "region", + type: "textInput", + label: i18n.trans("region"), + defaultValue: "oss-cn-hangzhou", + rules: [{ required: true }], + }, + ], + } as const; + return dataSourceConfig; +}; + +export default getDataSourceConfig; + +export type DataSourceDataType = ConfigToType>; diff --git a/server/node-service/src/plugins/aliyunOss/i18n/en.ts b/server/node-service/src/plugins/aliyunOss/i18n/en.ts new file mode 100644 index 000000000..28101e730 --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/i18n/en.ts @@ -0,0 +1,30 @@ +export const en = { + name: "OSS", + description: "Supports Aliyun Object Storage Service (Based on STS)", + skRequiredMessage: "Please input the SecretKey", + akRequiredMessage: "Please input the AccessKey", + arnRequiredMessage: "Please input the ARN", + endpointUrlTooltip: "Endpoint url of STS service", + arnTooltip: "The global resource descriptor for the role", + bucket: "Bucket", + returnSignedUrl: "Return signed url", + actions: "Actions", + prefix: "Prefix to filter", + delimiter: "Delimiter", + limit: "Limit", + fileName: "File name", + dataType: "Data type", + data: "Data", + dataTooltip:"The content of the data only supports BASE64 encoding, for example: window.btoa(xxx).", + region: "OSS Region", + messages: { + bucketRequired: "Bucket is required", + }, + actionName: { + listBuckets: "List buckets", + listObjects: "List files", + uploadFile: "Upload file", + readFile: "Read file", + deleteFile: "Delete file", + }, +}; diff --git a/server/node-service/src/plugins/aliyunOss/i18n/index.ts b/server/node-service/src/plugins/aliyunOss/i18n/index.ts new file mode 100644 index 000000000..77e5c0346 --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/i18n/index.ts @@ -0,0 +1,9 @@ +import { en } from "./en"; +import { zh } from "./zh"; +import { I18n } from "../../../common/i18n"; + +export default function getI18nTranslator(languages: string[]) { + return new I18n({ zh, en }, languages); +} + +export type AliyunOssI18nTranslator = ReturnType; diff --git a/server/node-service/src/plugins/aliyunOss/i18n/zh.ts b/server/node-service/src/plugins/aliyunOss/i18n/zh.ts new file mode 100644 index 000000000..d9f2db3ca --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/i18n/zh.ts @@ -0,0 +1,32 @@ +import { en } from "./en"; + +export const zh: typeof en = { + name: "阿里云对象存储", + description: "支持OSS对象存储服务(基于 STS 身份认证)", + skRequiredMessage: "请输入 SecretKey", + akRequiredMessage: "请输入 AccessKey", + arnRequiredMessage: "请输入阿里云ARN", + endpointUrlTooltip: "STS 服务接入点", + arnTooltip: "角色的全局资源描述符", + bucket: "存储桶", + region: "OSS 区域", + returnSignedUrl: "返回文件签名地址", + actions: "方法", + prefix: "前缀", + delimiter: "分隔符", + limit: "最大文件数", + fileName: "文件名", + dataType: "数据类型", + data: "数据", + dataTooltip:"数据内容仅支持 BASE64 编码,例:window.btoa(xxx)", + messages: { + bucketRequired: "需要提供存储桶名称", + }, + actionName: { + listBuckets: "查询桶列表", + listObjects: "获取文件列表", + uploadFile: "上传文件", + readFile: "读取文件", + deleteFile: "删除文件", + }, +}; diff --git a/server/node-service/src/plugins/aliyunOss/index.ts b/server/node-service/src/plugins/aliyunOss/index.ts new file mode 100644 index 000000000..74977ecd6 --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/index.ts @@ -0,0 +1,39 @@ +import { DataSourcePluginFactory, PluginContext } from "lowcoder-sdk/dataSource"; +import getI18nTranslator from "./i18n"; +import getDataSourceConfig, { DataSourceDataType } from "./dataSourceConfig"; +import run, { validateDataSourceConfig } from "./run"; +import getQueryConfig, { ActionDataType } from "./queryConfig"; +import { ServiceError } from "../../common/error"; + +const ossPlugin: DataSourcePluginFactory = (context: PluginContext) => { + const i18n = getI18nTranslator(context.languages); + return { + id: "oss", + name: i18n.trans("name"), + icon: "https://img.alicdn.com/tfs/TB1_ZXuNcfpK1RjSZFOXXa6nFXa-32-32.ico", + description: i18n.trans("description"), + category: "api", + dataSourceConfig: getDataSourceConfig(i18n), + queryConfig: getQueryConfig(i18n), + + validateDataSourceConfig: async (dataSourceConfig: DataSourceDataType) => { + return validateDataSourceConfig(dataSourceConfig); + }, + + run: async ( + action: ActionDataType, + dataSourceConfig: DataSourceDataType, + ctx: PluginContext + ) => { + const i18n = getI18nTranslator(ctx.languages); + try { + return await run(action, dataSourceConfig, i18n); + } catch (e:any) { + throw new ServiceError(e.message, 400); + } + }, + }; + }; + + export default ossPlugin; + \ No newline at end of file diff --git a/server/node-service/src/plugins/aliyunOss/queryConfig.ts b/server/node-service/src/plugins/aliyunOss/queryConfig.ts new file mode 100644 index 000000000..901aa9978 --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/queryConfig.ts @@ -0,0 +1,80 @@ +import { ActionParamConfig, Config, ConfigToType, QueryConfig } from "lowcoder-sdk/dataSource"; +import { AliyunOssI18nTranslator } from "./i18n"; + +function getQueryConfig(i18n: AliyunOssI18nTranslator) { + const bucketActionParam = { + key: "bucket", + type: "textInput", + label: i18n.trans("bucket"), + } as const; + + const queryConfig = { + type: "query", + label: i18n.trans("actions"), + actions: [ + // { + // actionName: "listBuckets", + // label: i18n.trans("actionName.listBuckets"), + // params: [], + // }, + { + actionName: "listObjects", + label: i18n.trans("actionName.listObjects"), + params: [ + bucketActionParam, + { + key: "prefix", + type: "textInput", + label: i18n.trans("prefix"), + }, + { + key: "delimiter", + type: "textInput", + label: i18n.trans("delimiter"), + }, + { + key: "limit", + type: "numberInput", + defaultValue: 10, + label: i18n.trans("limit"), + } + ], + }, + { + actionName: "uploadData", + label: i18n.trans("actionName.uploadFile"), + params: [ + bucketActionParam, + { + key: "fileName", + type: "textInput", + label: i18n.trans("fileName"), + }, + { + key: "data", + type: "textInput", + label: i18n.trans("data"), + tooltip: i18n.trans("dataTooltip"), + }, + ], + }, + // { + // actionName: "deleteFile", + // label: i18n.trans("actionName.deleteFile"), + // params: [ + // bucketActionParam, + // { + // key: "fileName", + // type: "textInput", + // label: i18n.trans("fileName"), + // }, + // ], + // }, + ], + } as const; + return queryConfig; +} + +export type ActionDataType = ConfigToType>; + +export default getQueryConfig; diff --git a/server/node-service/src/plugins/aliyunOss/run.ts b/server/node-service/src/plugins/aliyunOss/run.ts new file mode 100644 index 000000000..b74cb00d8 --- /dev/null +++ b/server/node-service/src/plugins/aliyunOss/run.ts @@ -0,0 +1,143 @@ +// import { +// OSS +// } from "ali-oss"; +import OSS from "ali-oss"; +import { STS } from 'ali-oss'; +import { ServiceError } from "../../common/error"; +import { DataSourceDataType } from "./dataSourceConfig"; +import { AliyunOssI18nTranslator } from "./i18n"; +import { ActionDataType } from "./queryConfig"; +import { P } from "pino"; +import { query } from "express"; +import { Readable } from "stream"; + +interface StsCredential { + AccessKeyId: string; + AccessKeySecret: string; + SecurityToken: string; + Expiration: Date; +} +var stsCredential: StsCredential; +async function loginWithSts(params: DataSourceDataType): Promise { + if (stsCredential && new Date().getTime() < stsCredential.Expiration.getTime()) { + return stsCredential; + } + let sts = new STS({ + // 填写步骤1创建的RAM用户AccessKey。 + accessKeyId: params.accessKeyId, + accessKeySecret: params.accessKeySecret, + }); + let res = await sts.assumeRole(params.arn, ``, 3000, 'lowcoder'); + var { AccessKeyId, AccessKeySecret, SecurityToken, Expiration } = res.credentials; + stsCredential = { + AccessKeyId, + AccessKeySecret, + SecurityToken, + Expiration: new Date(Expiration) + }; + return stsCredential; +} + +async function getClient(params: DataSourceDataType) { + var stsCredential = await loginWithSts(params); + return new OSS({ + region: params.region, + accessKeyId: stsCredential.AccessKeyId, + accessKeySecret: stsCredential.AccessKeySecret, + stsToken: stsCredential.SecurityToken, + refreshSTSToken: async () => { + var res = await loginWithSts(params); + return { + accessKeyId: res.AccessKeyId, + accessKeySecret: res.AccessKeySecret, + stsToken: res.SecurityToken, + } + } + }); +} + +function getBucket(actionConfig: ActionDataType, dataSourceConfig: DataSourceDataType) { + if ("bucket" in actionConfig) { + return actionConfig.bucket; + } + return ""; +} + +export async function validateDataSourceConfig(dataSourceConfig: DataSourceDataType) { + try { + const client = getClient(dataSourceConfig); + return{ + success:true + }; + } catch (e) { + if (e) { + return { + success: false, + message: String(e), + }; + } + throw e; + } +} + +export default async function run( + action: ActionDataType, + dataSourceConfig: DataSourceDataType, + i18n: AliyunOssI18nTranslator +) { + const client = await getClient(dataSourceConfig); + const bucket = getBucket(action, dataSourceConfig); + client.useBucket(bucket); + + // list + if (action.actionName === "listObjects") { + if (!bucket) { + throw new ServiceError(i18n.trans("messages.bucketRequired"), 400); + } + const res = await client.listV2({ + prefix: action.prefix, + delimiter: action.delimiter, + "max-keys": String(action.limit ?? 100), + }, {}); + + const files = []; + for (const i of res.objects || []) { + files.push({ + name: i.name || "", + size: i.size, + lastModified: i.lastModified, + etag: i.etag, + url: i.url, + }); + } + return files; + } + + // upload + if (action.actionName === "uploadData") { + const buf = Buffer.from(action.data, ("base64") as BufferEncoding); + const r = new Readable(); + r.push(buf); + r.push(null); + let result = await client.putStream(action.fileName, r); + return { + fileName: action.fileName, + url: getUrl(action.fileName, client), + } + + // if (action.actionName === "deleteFile") { + // await client.send( + // new DeleteObjectCommand({ + // Bucket: bucket, + // Key: action.fileName, + // }) + // ); + // return { + // success: true, + // }; + // } + } + function getUrl(fileName: string, client: OSS) { + return client.signatureUrl(fileName); + } +} diff --git a/server/node-service/src/plugins/index.ts b/server/node-service/src/plugins/index.ts index 3c78b5363..22d0d8a23 100644 --- a/server/node-service/src/plugins/index.ts +++ b/server/node-service/src/plugins/index.ts @@ -32,6 +32,7 @@ import faunaPlugin from "./fauna"; import huggingFaceInferencePlugin from "./huggingFaceInference"; import didPlugin from "./did"; import bigQueryPlugin from "./bigQuery"; +import ossPlugin from "./aliyunOss"; import appConfigPlugin from "./appconfig"; import tursoPlugin from "./turso"; import postmanEchoPlugin from "./postmanEcho"; @@ -87,6 +88,7 @@ let plugins: (DataSourcePlugin | DataSourcePluginFactory)[] = [ googleCloudStorage, supabasePlugin, cloudinaryPlugin, + ossPlugin, // Project Management asanaPlugin,