diff --git a/.eslintrc.js b/.eslintrc.js index 3b9407da9..85f1826f3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -31,7 +31,6 @@ module.exports = { ResizeSensor: false, define: false, BK_USER_MANAGE_HOST: false, - BK_API_URL_TMPL: false, BKPAAS_ENVIRONMENT: false, BK_ITSM_URL: false, BK_APP_APIGW_PREFIX: false, diff --git a/bk.config.js b/bk.config.js index f65a4b42b..6a20cdcb3 100644 --- a/bk.config.js +++ b/bk.config.js @@ -125,9 +125,16 @@ module.exports = { chunks: 'all', reuseExistingChunk: true }, - xlsxTypeormMoment: { - name: 'xlsx-typeorm-moment', - test: /(xlsx)|(typeorm)|(moment)/, + exceljs: { + name: 'exceljs', + test: /[\\/]node_modules[\\/]exceljs[\\/]/, + priority: 2, + chunks: 'all', + reuseExistingChunk: true + }, + typeormMoment: { + name: 'typeorm-moment', + test: /[\\/]node_modules[\\/](typeorm|moment)[\\/]/, priority: 1, chunks: 'all', reuseExistingChunk: true diff --git a/lib/client/index.html b/lib/client/index.html index 85e75bd03..3e42fe5e1 100644 --- a/lib/client/index.html +++ b/lib/client/index.html @@ -31,7 +31,6 @@ var BK_COMPONENT_API_URL = '<%= process.env.BK_COMPONENT_API_URL %>' var BKPAAS_BK_DOMAIN = '<%= process.env.BKPAAS_BK_DOMAIN %>' var BK_SHARED_RES_URL = '<%= process.env.BK_SHARED_RES_URL %>' - var BK_API_URL_TMPL = '{{ BK_API_URL_TMPL }}' var BKPAAS_ENGINE_REGION = '<%= process.env.BK_PAAS_ENGINE_REGION %>' var BK_APP_APIGW_PREFIX = '<%= process.env.BK_API_GATEWAY_ORIGIN.replace('{api_name}', 'bk-lesscode') + '/' + process.env.BK_LESSCODE_ENVIRONMENT || '' %>' var BK_IAM_HOST = '<%= process.env.BK_IAM_HOST %>' diff --git a/lib/client/preview.html b/lib/client/preview.html index af0f1d28c..34286d0ac 100644 --- a/lib/client/preview.html +++ b/lib/client/preview.html @@ -129,7 +129,6 @@ > var BK_STATIC_URL = '<%= process.env.BK_STATIC_URL %>' var BKPAAS_ENVIRONMENT = '<%= process.env.BK_PAAS_ENVIRONMENT %>' - var BK_API_URL_TMPL = '{{ BK_API_URL_TMPL }}' var BKPAAS_ENGINE_REGION = '<%= process.env.BK_PAAS_ENGINE_REGION %>' var BK_APP_APIGW_PREFIX = '<%= process.env.BK_API_GATEWAY_ORIGIN.replace('{api_name}', 'bk-lesscode') + '/' + process.env.BK_LESSCODE_ENVIRONMENT || '' %>' var BK_IAM_HOST = '<%= process.env.BK_IAM_HOST %>' diff --git a/lib/client/src/api/index.js b/lib/client/src/api/index.js index a28faf300..18ddad99c 100644 --- a/lib/client/src/api/index.js +++ b/lib/client/src/api/index.js @@ -12,7 +12,7 @@ import axios from 'axios' import cookie from 'cookie' -import RequestError from './request-error' +import RequestError, { formatErrorMessage } from './request-error' import CachedPromise from './cached-promise' import RequestQueue from './request-queue' import { bus } from '../common/bus' @@ -20,6 +20,19 @@ import { bkMessage } from 'bk-magic-vue' import { showLoginModal } from '@blueking/login-modal' +const getResponseMessage = (response) => { + const body = response?.data + if (body && typeof body === 'object') { + const bodyMessage = body.message ?? body.msg ?? body.data?.message + if (bodyMessage != null && bodyMessage !== '') { + return formatErrorMessage(bodyMessage) + } + } + return response?.statusText || formatErrorMessage() +} + +const rejectRequestError = (code, message, response) => Promise.reject(new RequestError(code, message, response)) + // 解析错误 axios.interceptors.response.use( // 后端 API 响应成功(http status 200) @@ -42,33 +55,38 @@ axios.interceptors.response.use( bus.$emit('not-exist', data.message) return data // 后端业务处理报错 - default: - const { code, message = window.i18n.t('系统错误') } = response.data - throw new RequestError(code, message, response) + default: { + const body = response.data + const { code } = body + const message = body.message ?? body.msg ?? body.data?.message + return rejectRequestError(code, formatErrorMessage(message), response) + } } }, // 解析 http status code (非 200) error => { + if (error instanceof RequestError) { + return Promise.reject(error) + } const { response } = error if (response) { - // 默认提示 http 状态码错误标记 - let message = response.statusText - // 兼容后端响应时通过body返回错误信息 - if (response.data && response.data.message) { - message = response.data.message - } - return Promise.reject(new RequestError(response.status || -1, message, response)) + return rejectRequestError(response.status || -1, getResponseMessage(response), response) } - return Promise.reject(new RequestError(-1, window.i18n.t('{0} 无法访问', [process.env.BK_AJAX_URL_PREFIX]))) + return rejectRequestError(-1, window.i18n.t('{0} 无法访问', [process.env.BK_AJAX_URL_PREFIX])) } ) // 处理错误 axios.interceptors.response.use(undefined, error => { + const requestError = error instanceof RequestError + ? error + : (error?.response + ? new RequestError(error.response.status || -1, getResponseMessage(error.response), error.response) + : error) const { code, message, response - } = error + } = requestError switch (code) { // 用户登录状态失效 case 401: @@ -87,7 +105,7 @@ axios.interceptors.response.use(undefined, error => { bkMessage({ theme: 'error', message, ellipsisLine: 3, limit: 1 }) } } - return Promise.reject(error) + return Promise.reject(requestError) }) const http = { diff --git a/lib/client/src/api/request-error.js b/lib/client/src/api/request-error.js index b9a25a73b..3c788342b 100644 --- a/lib/client/src/api/request-error.js +++ b/lib/client/src/api/request-error.js @@ -1,8 +1,93 @@ +const DEFAULT_ERROR_MESSAGE = '系统错误' + +const getDefaultMessage = () => { + try { + return window.i18n?.t?.(DEFAULT_ERROR_MESSAGE) || DEFAULT_ERROR_MESSAGE + } catch { + return DEFAULT_ERROR_MESSAGE + } +} + +/** + * 将接口返回的 message 转为可展示的字符串(避免 [object Object]) + */ +export const formatErrorMessage = (message, fallback = getDefaultMessage()) => { + if (message == null || message === '') { + return fallback + } + if (typeof message === 'string') { + return message + } + if (typeof message === 'number' || typeof message === 'boolean') { + return String(message) + } + if (message instanceof Error) { + return formatErrorMessage(message.message, fallback) + } + if (Array.isArray(message)) { + if (typeof message[0] === 'string' && window.i18n?.t) { + try { + return window.i18n.t(...message) + } catch { + // fall through + } + } + return message + .map(item => formatErrorMessage(item, '')) + .filter(Boolean) + .join('; ') || fallback + } + if (typeof message === 'object') { + if (Object.keys(message).length === 0) { + return fallback + } + if (message.message != null) { + return formatErrorMessage(message.message, fallback) + } + if (message.msg != null) { + return formatErrorMessage(message.msg, fallback) + } + if (message.content != null) { + return formatErrorMessage(message.content, fallback) + } + if (message.detail != null) { + return formatErrorMessage(message.detail, fallback) + } + if (message.error != null) { + return formatErrorMessage(message.error, fallback) + } + if (typeof message.key === 'string' && window.i18n?.t) { + try { + return window.i18n.t(message.key, message.params || message.values || message.args) + } catch { + // fall through + } + } + const locale = window.i18n?.locale || 'zh-cn' + const localeMessage = message[locale] ?? message['zh-cn'] ?? message.en ?? message.zh + if (localeMessage != null) { + return formatErrorMessage(localeMessage, fallback) + } + const stringValues = Object.values(message).filter(value => typeof value === 'string') + if (stringValues.length === 1) { + return stringValues[0] + } + try { + return JSON.stringify(message) + } catch { + return fallback + } + } + return String(message) +} + export default class RequestError extends Error { constructor (code, message, response) { - super() + const displayMessage = formatErrorMessage(message) + super(displayMessage) + this.name = 'RequestError' this.code = code - this.message = message + this.message = displayMessage this.response = response } } diff --git a/lib/client/src/common/util.js b/lib/client/src/common/util.js index 825db23bf..aa5a0ab5a 100644 --- a/lib/client/src/common/util.js +++ b/lib/client/src/common/util.js @@ -25,6 +25,19 @@ export function transformHtmlToVnode (html) { return htmlComponent._render() } +const DOWNLOAD_MIME_MAP = { + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + xls: 'application/vnd.ms-excel', + sql: 'text/plain', + json: 'application/json', + zip: 'application/zip' +} + +function getDownloadMimeType (filename = '') { + const ext = filename.split('.').pop()?.toLowerCase() + return DOWNLOAD_MIME_MAP[ext] || 'application/octet-stream' +} + /** * 前端下载文件 * @param {*} source 文件内容 @@ -32,7 +45,7 @@ export function transformHtmlToVnode (html) { */ export function downloadFile (source, filename = 'lesscode.txt') { const downloadEl = document.createElement('a') - const blob = new Blob([source]) + const blob = new Blob([source], { type: getDownloadMimeType(filename) }) downloadEl.download = filename const url = URL.createObjectURL(blob) downloadEl.href = url diff --git a/lib/client/src/components/flow-form-comp/components/custom-buttons.vue b/lib/client/src/components/flow-form-comp/components/custom-buttons.vue index 851a8cccb..2e35050d8 100644 --- a/lib/client/src/components/flow-form-comp/components/custom-buttons.vue +++ b/lib/client/src/components/flow-form-comp/components/custom-buttons.vue @@ -23,7 +23,7 @@ > - import * as XLSX from 'xlsx' + import { downloadXlsxFromAoa } from 'shared/excel' export default { name: 'CustomButtons', @@ -99,10 +99,7 @@ body.push(row) }) - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(wb, ws) - XLSX.writeFile(wb, `${this.nodeName || this.tableName}.xlsx`) + await downloadXlsxFromAoa(`${this.nodeName || this.tableName}.xlsx`, 'Sheet1', [header, ...body]) } catch (e) { console.log(e.message || e) } finally { diff --git a/lib/client/src/components/flow-form-comp/components/table-cell-detail.vue b/lib/client/src/components/flow-form-comp/components/table-cell-detail.vue index fbd0cce78..212fababa 100644 --- a/lib/client/src/components/flow-form-comp/components/table-cell-detail.vue +++ b/lib/client/src/components/flow-form-comp/components/table-cell-detail.vue @@ -42,7 +42,7 @@ {{ $t('点击下载') }} -- - + {{ value[field.key] || '--' }} diff --git a/lib/client/src/components/render/pc/widget/data-manage-container/form-data-manage/preview/components/buttons/index.js b/lib/client/src/components/render/pc/widget/data-manage-container/form-data-manage/preview/components/buttons/index.js index ba4fa9ce3..2f9f573ac 100644 --- a/lib/client/src/components/render/pc/widget/data-manage-container/form-data-manage/preview/components/buttons/index.js +++ b/lib/client/src/components/render/pc/widget/data-manage-container/form-data-manage/preview/components/buttons/index.js @@ -1,5 +1,5 @@ import { h } from 'bk-lesscode-render' -import * as XLSX from 'xlsx' +import { downloadXlsxFromAoa } from 'shared/excel' import './index.postcss' export default { @@ -62,10 +62,7 @@ export default { body.push(row) }) - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(wb, ws) - XLSX.writeFile(wb, `${this.tableName}.xlsx`) + await downloadXlsxFromAoa(`${this.tableName}.xlsx`, 'Sheet1', [header, ...body]) } catch (e) { console.log(e.message || e) } finally { diff --git a/lib/client/src/form-engine/fields/textarea/index.js b/lib/client/src/form-engine/fields/textarea/index.js index 7a4263503..d4437287b 100644 --- a/lib/client/src/form-engine/fields/textarea/index.js +++ b/lib/client/src/form-engine/fields/textarea/index.js @@ -1,4 +1,5 @@ import { h } from 'bk-lesscode-render' +import './index.postcss' export default { name: 'bkform-engine-textarea', diff --git a/lib/client/src/form-engine/fields/textarea/index.postcss b/lib/client/src/form-engine/fields/textarea/index.postcss new file mode 100644 index 000000000..7e3d41d85 --- /dev/null +++ b/lib/client/src/form-engine/fields/textarea/index.postcss @@ -0,0 +1,8 @@ +.bkform-engine-field-widget { + .bk-lesscode-textarea, + .bk-textarea { + textarea { + flex: 1; + } + } +} \ No newline at end of file diff --git a/lib/client/src/main.js b/lib/client/src/main.js index a65cdae1d..b20f562fd 100644 --- a/lib/client/src/main.js +++ b/lib/client/src/main.js @@ -46,6 +46,7 @@ import MgContentLoader from '@/components/loader' import './directives' import { BkXssFilterDirective } from '@blueking/xss-filter' import { IAM_ACTION } from 'shared/constant' +import { escapeHtml } from 'shared/security/xss-protection' import './bk-icon/style.css' import './bk-icon/iconcool.js' @@ -111,9 +112,11 @@ auth.requestCurrentUser().then(user => { const h2Style = 'font-size: 20px;color: #979797; margin: 32px 0;font-weight: normal' + const safeMessage = escapeHtml(message) + const content = '' + `
` - + `

${message}

` + + `

${safeMessage}

` + '
' document.write(content) diff --git a/lib/client/src/views/home/index.vue b/lib/client/src/views/home/index.vue index ba3b351ae..bae53ca13 100644 --- a/lib/client/src/views/home/index.vue +++ b/lib/client/src/views/home/index.vue @@ -94,7 +94,7 @@
{{item.name}}
- +

{{footerCopyrightContent}}

diff --git a/lib/client/src/views/index/components/material-panel/components/common/search-box/index.vue b/lib/client/src/views/index/components/material-panel/components/common/search-box/index.vue index ff4989355..de82e6eb1 100644 --- a/lib/client/src/views/index/components/material-panel/components/common/search-box/index.vue +++ b/lib/client/src/views/index/components/material-panel/components/common/search-box/index.vue @@ -50,6 +50,8 @@ > + import { escapeHtml } from 'shared/security/xss-protection' + const encodeRegexp = (paramStr) => { const regexpKeyword = [ '\\', '.', '*', '-', '{', '}', '[', ']', '^', '(', ')', '$', '+', '?', '|' @@ -71,9 +73,13 @@ const textClass = 'text' const { node, query } = ctx.props const searchName = node.displayName ? `${node.name} ${node.displayName}` : node.name + + const escapedSearchName = escapeHtml(searchName) + const escapedQuery = query ? escapeHtml(query) : '' + return ( 1ドル') : searchName + query ? escapedSearchName.replace(new RegExp(`(${escapedQuery})`, 'i'), '1ドル') : escapedSearchName } class={textClass}> ) } diff --git a/lib/client/src/views/project/data-source-manage/data-table/common/use-download-demo.ts b/lib/client/src/views/project/data-source-manage/data-table/common/use-download-demo.ts index 2a3556312..4b230e706 100644 --- a/lib/client/src/views/project/data-source-manage/data-table/common/use-download-demo.ts +++ b/lib/client/src/views/project/data-source-manage/data-table/common/use-download-demo.ts @@ -110,18 +110,18 @@ const demoTable = [{ }] // 下载表结构示例 -export const downloadStructTemplate = (type) => { +export const downloadStructTemplate = async (type) => { const fileName = type === 'sql' ? 'bklesscode-struct-demo.sql' : '' - const files = generateExportStruct(demoTable, type, fileName) + const files = await generateExportStruct(demoTable, type, fileName) files.forEach(({ name, content }) => { downloadFile(content, name) }) } // 下载数据示例 -export const downloadDataTemplate = (type, demoData) => { +export const downloadDataTemplate = async (type, demoData) => { const fileName = type === 'sql' ? 'bklesscode-data-demo.sql' : '' - const files = generateExportDatas(demoData, type, fileName) + const files = await generateExportDatas(demoData, type, fileName) files.forEach(({ name, content }) => { downloadFile(content, name) }) diff --git a/lib/client/src/views/project/data-source-manage/data-table/data-manage/render-data.vue b/lib/client/src/views/project/data-source-manage/data-table/data-manage/render-data.vue index b38b81585..b0b21ca7a 100644 --- a/lib/client/src/views/project/data-source-manage/data-table/data-manage/render-data.vue +++ b/lib/client/src/views/project/data-source-manage/data-table/data-manage/render-data.vue @@ -571,7 +571,7 @@ window.open(`/api/data-source/exportDatas/projectId/${projectId}/fileType/${fileType}/tableName/${activeTable.value.tableName}/environment/${environment.value.key}?x-timezone-offset=${new Date().getTimezoneOffset()}`) } - const exportSelectDatas = (fileType) => { + const exportSelectDatas = async (fileType) => { // 生产 sql 语法不需要id const datas = [{ tableName: activeTable.value.tableName, @@ -588,7 +588,7 @@ transferTimezone(datas[0].list) } const fileName = fileType === DATA_FILE_TYPE.SQL ? `bklesscode-data-${projectId}.sql` : '' - const files = generateExportDatas(datas, fileType, fileName) + const files = await generateExportDatas(datas, fileType, fileName) files.forEach(({ name, content }) => { downloadFile(content, name) }) @@ -607,22 +607,16 @@ } // 解析导入的数据 - const parseImport = ({ data, type }) => { - return new Promise((resolve, reject) => { - try { - const [list] = handleImportData( - [data], - type, - activeTable.value.columns.map(column => column.name) - ) - resolve({ - data: list, - message: type === DATA_FILE_TYPE.XLSX ? window.i18n.t('解析到【{0}】条数据,点击导入后插入到数据库', [list.length]) : '' - }) - } catch (error) { - reject(error) - } - }) + const parseImport = async ({ data, type }) => { + const [list] = await handleImportData( + [data], + type, + activeTable.value.columns.map(column => column.name) + ) + return { + data: list, + message: type === DATA_FILE_TYPE.XLSX ? window.i18n.t('解析到【{0}】条数据,点击导入后插入到数据库', [list.length]) : '' + } } // 执行导入 diff --git a/lib/client/src/views/project/data-source-manage/data-table/table-design/create-table.vue b/lib/client/src/views/project/data-source-manage/data-table/table-design/create-table.vue index 33f229374..e83c36452 100644 --- a/lib/client/src/views/project/data-source-manage/data-table/table-design/create-table.vue +++ b/lib/client/src/views/project/data-source-manage/data-table/table-design/create-table.vue @@ -203,23 +203,16 @@ } } // 解析导入的表结构 - const parseImport = ({ data, type }) => { - return new Promise((resolve, reject) => { - try { - const [tableInfo] = handleImportStruct([data], type) - const columns = [ - ...BASE_COLUMNS(), - ...tableInfo.columns.filter(column => !BASE_COLUMNS().find(baseColumn => baseColumn.name === column.name)) - ] - // 过滤掉基础字段设置,使用系统内置 - resolve({ - data: columns, - message: window.i18n.t('解析到【{0}】个字段,请点击导入后修改字段配置', [columns.length]) - }) - } catch (error) { - reject(error) - } - }) + const parseImport = async ({ data, type }) => { + const [tableInfo] = await handleImportStruct([data], type) + const columns = [ + ...BASE_COLUMNS(), + ...tableInfo.columns.filter(column => !BASE_COLUMNS().find(baseColumn => baseColumn.name === column.name)) + ] + return { + data: columns, + message: window.i18n.t('解析到【{0}】个字段,请点击导入后修改字段配置', [columns.length]) + } } // 执行导入 const handleImport = (data) => { diff --git a/lib/client/src/views/project/data-source-manage/data-table/table-design/edit-table.vue b/lib/client/src/views/project/data-source-manage/data-table/table-design/edit-table.vue index fffbbf762..2cccad7ea 100644 --- a/lib/client/src/views/project/data-source-manage/data-table/table-design/edit-table.vue +++ b/lib/client/src/views/project/data-source-manage/data-table/table-design/edit-table.vue @@ -235,23 +235,16 @@ }) } // 解析导入的表结构 - const parseImport = ({ data, type }) => { - return new Promise((resolve, reject) => { - try { - const [tableInfo] = handleImportStruct([data], type) - const columns = [ - ...BASE_COLUMNS(), - ...tableInfo.columns.filter(column => !BASE_COLUMNS().find(baseColumn => baseColumn.name === column.name)) - ] - // 过滤掉基础字段设置,使用系统内置 - resolve({ - data: columns, - message: window.i18n.t('解析到【{0}】个字段,请点击导入后修改字段配置', [columns.length]) - }) - } catch (error) { - reject(error) - } - }) + const parseImport = async ({ data, type }) => { + const [tableInfo] = await handleImportStruct([data], type) + const columns = [ + ...BASE_COLUMNS(), + ...tableInfo.columns.filter(column => !BASE_COLUMNS().find(baseColumn => baseColumn.name === column.name)) + ] + return { + data: columns, + message: window.i18n.t('解析到【{0}】个字段,请点击导入后修改字段配置', [columns.length]) + } } // 执行导入 const handleImport = (data) => { diff --git a/lib/client/src/views/project/data-source-manage/data-table/table-design/show-table.vue b/lib/client/src/views/project/data-source-manage/data-table/table-design/show-table.vue index 4ec6dee8b..acfb558de 100644 --- a/lib/client/src/views/project/data-source-manage/data-table/table-design/show-table.vue +++ b/lib/client/src/views/project/data-source-manage/data-table/table-design/show-table.vue @@ -115,13 +115,13 @@ }) } - const exportTables = (fileType) => { + const exportTables = async (fileType) => { const tables = [{ ...tableStatus.basicInfo, columns: tableStatus.data }] const fileName = fileType === 'sql' ? `bklesscode-struct-${tableStatus.basicInfo.tableName}.sql` : '' - const files = generateExportStruct(tables, fileType, fileName) + const files = await generateExportStruct(tables, fileType, fileName) files.forEach(({ name, content }) => { downloadFile(content, name) }) diff --git a/lib/client/src/views/project/data-source-manage/data-table/table-design/table-list/list/index.vue b/lib/client/src/views/project/data-source-manage/data-table/table-design/table-list/list/index.vue index cebf4eac8..e47e6fb12 100644 --- a/lib/client/src/views/project/data-source-manage/data-table/table-design/table-list/list/index.vue +++ b/lib/client/src/views/project/data-source-manage/data-table/table-design/table-list/list/index.vue @@ -242,9 +242,9 @@ window.open(`/api/data-source/exportStruct/projectId/${projectId}/fileType/${fileType}`) } - const exportSelectTables = (fileType) => { + const exportSelectTables = async (fileType) => { const fileName = fileType === 'sql' ? `bklesscode-struct-${projectId}.sql` : '' - const files = generateExportStruct(listStatus.selectRows, fileType, fileName) + const files = await generateExportStruct(listStatus.selectRows, fileType, fileName) files.forEach(({ name, content }) => { downloadFile(content, name) }) diff --git a/lib/client/src/views/project/release/components/completed-info.vue b/lib/client/src/views/project/release/components/completed-info.vue index 1699453a7..ef0ee8d85 100644 --- a/lib/client/src/views/project/release/components/completed-info.vue +++ b/lib/client/src/views/project/release/components/completed-info.vue @@ -6,7 +6,7 @@

- +

{{ $t('已耗时:') }} @@ -36,7 +36,7 @@

- +

diff --git a/lib/client/src/views/project/release/components/completed-log.vue b/lib/client/src/views/project/release/components/completed-log.vue index cb004771e..2160529a3 100644 --- a/lib/client/src/views/project/release/components/completed-log.vue +++ b/lib/client/src/views/project/release/components/completed-log.vue @@ -26,7 +26,7 @@

- +
diff --git a/lib/client/src/views/project/release/components/deploy-log/deploy-stage/render-process-item.vue b/lib/client/src/views/project/release/components/deploy-log/deploy-stage/render-process-item.vue index 3ae363397..a3b9755eb 100644 --- a/lib/client/src/views/project/release/components/deploy-log/deploy-stage/render-process-item.vue +++ b/lib/client/src/views/project/release/components/deploy-log/deploy-stage/render-process-item.vue @@ -35,8 +35,8 @@

- - + +

diff --git a/lib/client/src/views/project/release/components/deploy-log/render-deploy-stage.vue b/lib/client/src/views/project/release/components/deploy-log/render-deploy-stage.vue index 0440b0eeb..7bbd54e83 100644 --- a/lib/client/src/views/project/release/components/deploy-log/render-deploy-stage.vue +++ b/lib/client/src/views/project/release/components/deploy-log/render-deploy-stage.vue @@ -4,8 +4,8 @@

- - + +

@@ -22,8 +22,8 @@

- - + +

diff --git a/lib/client/src/views/project/release/components/deploy-log/render-stage.vue b/lib/client/src/views/project/release/components/deploy-log/render-stage.vue index 3192efa3b..a8ba7ab0c 100644 --- a/lib/client/src/views/project/release/components/deploy-log/render-stage.vue +++ b/lib/client/src/views/project/release/components/deploy-log/render-stage.vue @@ -3,7 +3,7 @@
-

+ 

 
@@ -18,7 +18,7 @@
-

+ 

 
diff --git a/lib/client/src/views/project/release/components/running-info.vue b/lib/client/src/views/project/release/components/running-info.vue index a87f135eb..58b621e31 100644 --- a/lib/client/src/views/project/release/components/running-info.vue +++ b/lib/client/src/views/project/release/components/running-info.vue @@ -11,7 +11,7 @@

- +

{{$t('已耗时:')}} diff --git a/lib/client/src/views/project/release/publish-mixin.js b/lib/client/src/views/project/release/publish-mixin.js index 16e2f2a75..e2f9bf82b 100644 --- a/lib/client/src/views/project/release/publish-mixin.js +++ b/lib/client/src/views/project/release/publish-mixin.js @@ -1,4 +1,5 @@ import dayjs from 'dayjs' +import { safeJsonParse } from 'shared/security/xss-protection' export default { data () { @@ -16,7 +17,7 @@ export default { let time = info.createTime ? dayjs(info.createTime).format('YYYY-MM-DD HH:mm:ss') : '--' let updateUser = info.updateUser if (info.releaseType === 'FROM_V3') { - const paasInfo = info.fromPaasInfo ? JSON.parse(info.fromPaasInfo) : {} + const paasInfo = safeJsonParse(info.fromPaasInfo, {}) time = paasInfo.updateTime || time updateUser = paasInfo.updateUser || updateUser } diff --git a/lib/client/src/views/project/release/publish.vue b/lib/client/src/views/project/release/publish.vue index a785a8d15..80acf9c0a 100644 --- a/lib/client/src/views/project/release/publish.vue +++ b/lib/client/src/views/project/release/publish.vue @@ -97,7 +97,7 @@

- {{ $t('最近{0}版本:', [typeMap[latestInfo.isOffline]]) }} + {{ $t('最近{0}版本:', [typeMap[latestInfo.isOffline]]) }} ,{{ latestInfo.status === 'successful' ? $t('查看成功日志') : (latestInfo.status === 'failed' ? $t('查看失败日志') : $t('查看日志'))}}
diff --git a/lib/client/src/views/system/index.vue b/lib/client/src/views/system/index.vue index bb0151087..322a5eab1 100644 --- a/lib/client/src/views/system/index.vue +++ b/lib/client/src/views/system/index.vue @@ -59,7 +59,7 @@
- +

{{footerCopyrightContent}}

diff --git a/lib/client/src/views/system/operation/stats/export-button.vue b/lib/client/src/views/system/operation/stats/export-button.vue index 2737c13a5..d0bc57d27 100644 --- a/lib/client/src/views/system/operation/stats/export-button.vue +++ b/lib/client/src/views/system/operation/stats/export-button.vue @@ -3,7 +3,7 @@ > - import * as XLSX from 'xlsx' + import { downloadXlsxFromAoa } from 'shared/excel' export default { props: { @@ -89,9 +89,9 @@ }) const data = [header, ...body] - this.generateXlsx(`lesscode-stats-${this.name}.xlsx`, data, this.tableSheetName) + await this.generateXlsx(`lesscode-stats-${this.name}.xlsx`, data, this.tableSheetName) }, - handleCommonTimeDimExport () { + async handleCommonTimeDimExport () { const blocks = [] const max = this.list.length this.list.forEach((block, index) => { @@ -114,13 +114,10 @@ blocks.push([]) } }) - this.generateXlsx(`lesscode-stats-${this.name}-${this.dim}.xlsx`, blocks, window.i18n.t('按时间')) + await this.generateXlsx(`lesscode-stats-${this.name}-${this.dim}.xlsx`, blocks, window.i18n.t('按时间')) }, generateXlsx (fileName, data, sheetName) { - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet(data) - XLSX.utils.book_append_sheet(wb, ws, sheetName) - XLSX.writeFile(wb, fileName) + return downloadXlsxFromAoa(fileName, sheetName, data) }, fistLetterUpper (str) { return str.charAt(0).toUpperCase() + str.slice(1) diff --git a/lib/server/app.browser.js b/lib/server/app.browser.js index 8a93f531b..be53332ca 100644 --- a/lib/server/app.browser.js +++ b/lib/server/app.browser.js @@ -265,7 +265,7 @@ async function startServer () { app.use(convert.compose(allowedMethods)) app.context.render = views(resolve(__dirname, '..', IS_DEV ? 'client' : 'client/dist'), { - map: { html: 'swig' } + map: { html: 'ejs' } }) app.use(historyApiFallback({ diff --git a/lib/server/controller/data-source.js b/lib/server/controller/data-source.js index 282ef4c2e..3bf190771 100644 --- a/lib/server/controller/data-source.js +++ b/lib/server/controller/data-source.js @@ -724,7 +724,7 @@ export default class DataSourceController { }) const fileName = fileType === 'sql' ? `lesscode-struct-${projectId}.sql` : '' const zipName = `lesscode-struct-${projectId}` - const fileList = generateExportStruct(tables, fileType, fileName) + const fileList = await generateExportStruct(tables, fileType, fileName) return { fileList, zipName } } @@ -770,7 +770,7 @@ export default class DataSourceController { const fileName = fileType === DATA_FILE_TYPE.SQL ? `lesscode-data-${projectId}.sql` : '' const zipName = `lesscode-data-${projectId}` - const fileList = generateExportDatas(datas, fileType, fileName) + const fileList = await generateExportDatas(datas, fileType, fileName) return { fileList, zipName } } } diff --git a/lib/server/controller/open-api.js b/lib/server/controller/open-api.js index d5b5dda10..7f5535b98 100644 --- a/lib/server/controller/open-api.js +++ b/lib/server/controller/open-api.js @@ -17,6 +17,7 @@ import { myProject } from './iam' import { getPreviewDbConfig, getTables, getTableDetail } from '../service/business/preview-db-service' import DBEngineService from '../service/common/db-engine-service' +import { logger } from '../logger' const { createDemoProject } = require('./project') @@ -260,25 +261,132 @@ export const getProjectTableCols = async (ctx) => { } } +/** + * 增强的SQL安全检查函数 + * @param {string} sql SQL语句 + * @throws {Error} 如果检测到危险模式 + */ +const enhancedSqlSecurityCheck = (sql) => { + if (!sql || typeof sql !== 'string') { + throw new Error('SQL语句不能为空') + } + + const trimmedSql = sql.trim() + const upperCaseSql = trimmedSql.toUpperCase() + + // 基本检查:必须以SELECT开头 + if (!upperCaseSql.startsWith('SELECT')) { + throw new Error('仅支持SELECT查询语句') + } + + // 危险关键词检查(更全面) + const dangerousKeywords = [ + 'DROP DATABASE', 'TRUNCATE TABLE', 'DROP TABLE', 'CREATE TABLE', + 'DELETE FROM', 'ALTER TABLE', 'INSERT INTO', 'UPDATE SET', + 'GRANT', 'REVOKE', 'CREATE USER', 'DROP USER', 'SET PASSWORD', + 'LOAD DATA', 'OUTFILE', 'DUMPFILE', 'LOAD_FILE' + ] + + for (const keyword of dangerousKeywords) { + if (upperCaseSql.includes(keyword)) { + throw new Error(`检测到危险关键词: ${keyword}`) + } + } + + // 危险模式检查 + const dangerousPatterns = [ + /UNION\s+SELECT/i, // UNION注入 + /;\s*(DROP|DELETE|UPDATE|INSERT|ALTER|CREATE|GRANT|REVOKE)/i, // 多语句注入 + /--/, // SQL注释 + /\/\*/, // 多行注释开始 + /\*\//, // 多行注释结束 + /XP_CMDSHELL/i, // SQL Server命令执行 + /SP_EXECUTESQL/i, // SQL Server动态SQL + /EXEC\s*\(/i, // 执行函数 + /EXECUTE\s*\(/i, // 执行函数 + /INFORMATION_SCHEMA/i, // 信息模式 + /PERFORMANCE_SCHEMA/i, // 性能模式 + /MYSQL\./i, // MySQL系统表 + /\@\@/, // 系统变量 + /DATABASE\(\)/i, // 数据库函数 + /USER\(\)/i, // 用户函数 + /VERSION\(\)/i, // 版本函数 + /BENCHMARK\s*\(/i, // 基准测试函数 + /SLEEP\s*\(/i, // 延时函数 + /WAITFOR\s+DELAY/i, // SQL Server延时 + /PG_SLEEP\s*\(/i, // PostgreSQL延时 + /DBMS_PIPE\.RECEIVE_MESSAGE/i, // Oracle延时 + /INTO\s+OUTFILE/i, // 文件写入 + /INTO\s+DUMPFILE/i, // 文件转储 + /LOAD_FILE\s*\(/i, // 文件读取 + /CHAR\s*\(/i, // 字符编码绕过 + /ASCII\s*\(/i, // ASCII编码绕过 + /HEX\s*\(/i, // 十六进制编码绕过 + /UNHEX\s*\(/i, // 反十六进制编码 + /CONCAT\s*\(/i, // 字符串拼接(可能用于绕过) + /SUBSTRING\s*\(/i, // 子字符串(可能用于盲注) + /MID\s*\(/i, // 中间字符串 + /LEFT\s*\(/i, // 左侧字符串 + /RIGHT\s*\(/i, // 右侧字符串 + /IF\s*\(/i, // 条件函数(可能用于盲注) + /CASE\s+WHEN/i, // CASE语句(可能用于盲注) + /EXTRACTVALUE\s*\(/i, // XML函数(可能用于报错注入) + /UPDATEXML\s*\(/i, // XML更新函数 + /NAME_CONST\s*\(/i, // 名称常量函数 + /MULTIPOINT\s*\(/i, // 几何函数 + /POLYGON\s*\(/i, // 多边形函数 + /MULTIPOLYGON\s*\(/i, // 多多边形函数 + /LINESTRING\s*\(/i, // 线字符串函数 + /MULTILINESTRING\s*\(/i // 多线字符串函数 + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(sql)) { + throw new Error(`检测到危险的SQL模式: ${pattern.source}`) + } + } +} + // 执行sql查询,获取预览环境用户db表下数据(当前仅会开放给bk-vision) export const execQuerySql = async (ctx) => { - // 涉及到表变更sql的关键词 - const sqlKeywords = ['DROP DATABASE', 'TRRUNCATE TABLE', 'DROP TABLE', 'CREATE TABLE', 'DELETE FROM', 'ALTER TABLE', 'INSERT INTO'] const post = ctx.request.body const { projectId, sql } = post - const upperCaseSql = sql && sql.toUpperCase() - // 此接口只能执行查询语句,禁止执行任何涉及表变更的语句 - if (!upperCaseSql || !upperCaseSql.startsWith('SELECT') || sqlKeywords.indexOf(upperCaseSql) !== -1) { - ctx.throw(400, 'sql语句未以SELECT开头或含有变更数据表的危险关键词', { code: 400 }) + // 参数验证 + if (!projectId) { + ctx.throw(400, '缺少必要参数 projectId', { code: 400 }) + } + + if (!sql || typeof sql !== 'string') { + ctx.throw(400, 'SQL语句不能为空且必须为字符串类型', { code: 400 }) } try { + // 增强的安全检查 + enhancedSqlSecurityCheck(sql) + const previewDbConfig = await getPreviewDbConfig(projectId) const dbEngine = new DBEngineService(previewDbConfig) const res = await dbEngine.execSql(sql) success(ctx, res) } catch (err) { - ctx.throw(500, err.sqlMessage || err.message || err, { code: 500 }) + // 记录详细的错误日志 + logger.error({ + message: 'SQL查询执行失败', + projectId, + sql: sql.substring(0, 200), // 只记录前200个字符,避免日志过长 + error: err.message, + stack: err.stack, + timestamp: new Date().toISOString() + }) + + // 不暴露具体的SQL错误信息给客户端 + const safeErrorMessage = err.message.includes('检测到危险') || + err.message.includes('SQL语句') || + err.message.includes('仅支持SELECT') + ? err.message + : '查询执行失败,请检查SQL语句' + + ctx.throw(400, safeErrorMessage, { code: 400 }) } } diff --git a/lib/server/controller/page.js b/lib/server/controller/page.js index 1832b4713..c4bb924ce 100644 --- a/lib/server/controller/page.js +++ b/lib/server/controller/page.js @@ -624,7 +624,7 @@ export const pageLockStatus = async ctx => { }) } catch (error) { ctx.throwError({ - message: error + message: error?.message || error }) } } @@ -646,7 +646,7 @@ export const updatePageActive = async ctx => { }) } catch (error) { ctx.throwError({ - message: error + message: error?.message || error }) } } @@ -675,7 +675,7 @@ export const occupyPage = async ctx => { }) } catch (error) { ctx.throwError({ - message: error + message: error?.message || error }) } } diff --git a/lib/server/decorator/send/index.js b/lib/server/decorator/send/index.js index 5c55a9571..58fc235e0 100644 --- a/lib/server/decorator/send/index.js +++ b/lib/server/decorator/send/index.js @@ -14,6 +14,24 @@ const path = require('path') const fse = require('fs-extra') const { logger } = require('../../logger') +const sanitizePathName = (name = '') => name.replace(/[/\\]/g, '_') + +const toWriteBuffer = (content) => { + if (Buffer.isBuffer(content)) { + return content + } + if (content instanceof ArrayBuffer) { + return Buffer.from(content) + } + if (ArrayBuffer.isView(content)) { + return Buffer.from(content.buffer, content.byteOffset, content.byteLength) + } + if (typeof content === 'string') { + return Buffer.from(content, 'utf8') + } + return Buffer.from(content) +} + const outputError = (error, ctx) => { // 结构化日志记录错误 logger.error(error) @@ -67,8 +85,8 @@ export const OutputZip = () => { try { const { fileList } = await originValue.apply(this, [ctx]) let { zipName } = await originValue.apply(this, [ctx]) - // 去除路径名中的./\符号 - zipName = zipName?.replace(/[./\\]/g, '_') + // 去除路径分隔符,保留扩展名中的 . + zipName = sanitizePathName(zipName || '') curDownLoadDirPath = path.join(downloadTempPath, zipName) curDownLoadFilesPath = path.join(curDownLoadDirPath, zipName) // 确保下载临时目录有 @@ -77,10 +95,9 @@ export const OutputZip = () => { await fse.ensureDir(curDownLoadFilesPath) // 生成 文件列表 fileList.forEach((file) => { - let name = file.name || zipName - name = name?.replace(/[./\\]/g, '_') + const name = sanitizePathName(file.name || zipName) const filePath = path.join(curDownLoadFilesPath, name) - fse.writeFileSync(filePath, file.content, 'utf8') + fse.writeFileSync(filePath, toWriteBuffer(file.content)) }) // 生成 zip const curDownLoadZipPath = path.join(curDownLoadDirPath, `${zipName}.zip`) diff --git a/lib/server/middleware/error.js b/lib/server/middleware/error.js index 726df0045..91fd191c4 100644 --- a/lib/server/middleware/error.js +++ b/lib/server/middleware/error.js @@ -9,6 +9,19 @@ * specific language governing permissions and limitations under the License. */ +const formatErrorMessage = (message) => { + if (message == null || message === '') { + return global.i18n?.t?.('服务器错误') || '服务器错误' + } + if (typeof message === 'string') { + return message + } + if (message instanceof Error) { + return message.message || global.i18n?.t?.('服务器错误') || '服务器错误' + } + return String(message) +} + module.exports = () => { return async function (ctx, next) { /** @@ -26,7 +39,7 @@ module.exports = () => { message, code } = error - throw new global.BusinessError(message, code, status) + throw new global.BusinessError(formatErrorMessage(message), code, status) } } /** @@ -50,7 +63,7 @@ module.exports = () => { ctx.status = status ctx.body = { code, - message, + message: formatErrorMessage(message), data } diff --git a/lib/server/model/project-code.js b/lib/server/model/project-code.js index 5555d7a61..e23a3ad0b 100644 --- a/lib/server/model/project-code.js +++ b/lib/server/model/project-code.js @@ -616,10 +616,10 @@ const projectCode = { await this.writePackageJSON( path.join(targetPath, 'package.json'), [ - { name: 'moment', version: '2.29.4' }, + { name: 'moment', version: '2.30.1' }, { name: 'animate.css', version: '4.1.1' }, { name: 'swiper-element-animation', version: '1.1.0' }, - { name: 'swiper', version: '11.0.5' }, + { name: 'swiper', version: '^12.1.2' }, { name: 'lucky-canvas', version: '1.7.27' } ] ) diff --git a/lib/server/project-template/vue2/project-init-code/bk.config.js b/lib/server/project-template/vue2/project-init-code/bk.config.js index 1850942a6..5a228dfbc 100644 --- a/lib/server/project-template/vue2/project-init-code/bk.config.js +++ b/lib/server/project-template/vue2/project-init-code/bk.config.js @@ -47,6 +47,19 @@ module.exports = { target: serverAddress } ] + }, + optimization: { + splitChunks: { + cacheGroups: { + exceljs: { + name: 'exceljs', + test: /[\\/]node_modules[\\/]exceljs[\\/]/, + priority: 2, + chunks: 'all', + reuseExistingChunk: true + } + } + } } } } diff --git a/lib/server/project-template/vue2/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js b/lib/server/project-template/vue2/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js index e79262c90..d670e5523 100644 --- a/lib/server/project-template/vue2/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js +++ b/lib/server/project-template/vue2/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js @@ -1,4 +1,4 @@ -import * as XLSX from 'xlsx' +import { downloadXlsxFromAoa } from 'shared/excel' import './index.postcss' export default { @@ -61,10 +61,7 @@ export default { body.push(row) }) - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(wb, ws) - XLSX.writeFile(wb, `${this.tableName}.xlsx`) + await downloadXlsxFromAoa(`${this.tableName}.xlsx`, 'Sheet1', [header, ...body]) } catch (e) { console.log(e.message || e) } finally { diff --git a/lib/server/project-template/vue2/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue b/lib/server/project-template/vue2/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue index 37eff329c..2f09af5ad 100644 --- a/lib/server/project-template/vue2/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue +++ b/lib/server/project-template/vue2/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue @@ -24,7 +24,7 @@
> - import * as XLSX from 'xlsx' + import { downloadXlsxFromAoa } from 'shared/excel' export default { name: 'CustomButtons', @@ -100,10 +100,7 @@ body.push(row) }) - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(wb, ws) - XLSX.writeFile(wb, `${this.nodeName || this.tableName}.xlsx`) + await downloadXlsxFromAoa(`${this.nodeName || this.tableName}.xlsx`, 'Sheet1', [header, ...body]) } catch (e) { console.log(e.message || e) } finally { diff --git a/lib/server/project-template/vue2/project-init-code/lib/server/app.browser.js b/lib/server/project-template/vue2/project-init-code/lib/server/app.browser.js index be286d26f..af20e61ea 100644 --- a/lib/server/project-template/vue2/project-init-code/lib/server/app.browser.js +++ b/lib/server/project-template/vue2/project-init-code/lib/server/app.browser.js @@ -99,7 +99,7 @@ async function startServer () { app.use(convert.compose(allowedMethods)) app.context.render = views(resolve(__dirname, '..', IS_DEV ? 'client' : 'client/dist'), { - map: { html: 'swig' } + map: { html: 'ejs' } }) app.use(historyApiFallback({ diff --git a/lib/server/project-template/vue2/project-init-code/lib/server/service/data-service.js b/lib/server/project-template/vue2/project-init-code/lib/server/service/data-service.js index c2f2a1aa3..cf6acd9b7 100644 --- a/lib/server/project-template/vue2/project-init-code/lib/server/service/data-service.js +++ b/lib/server/project-template/vue2/project-init-code/lib/server/service/data-service.js @@ -249,9 +249,10 @@ export function getDataService (name = 'default', customEntityMap) { * @param {*} query 查询参数 * @returns 获取数据详情结果 */ - findOne (tableFileName, query = {}) { + async findOne (tableFileName, query = {}) { const repository = getRepositoryByName(tableFileName) - return repository.findOne({ where: transformQuery(query) }) || {} + const result = await repository.findOne({ where: transformQuery(query) }) + return result || {} }, /** diff --git a/lib/server/project-template/vue2/project-init-code/lib/shared/excel/index.js b/lib/server/project-template/vue2/project-init-code/lib/shared/excel/index.js new file mode 100644 index 000000000..a63f121c3 --- /dev/null +++ b/lib/server/project-template/vue2/project-init-code/lib/shared/excel/index.js @@ -0,0 +1,33 @@ +/** + * Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community Edition) available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +import ExcelJS from 'exceljs' + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +/** + * 浏览器端下载 xlsx + * @param {string} fileName + * @param {string} sheetName + * @param {Array>} rows + */ +export async function downloadXlsxFromAoa (fileName, sheetName, rows) { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet(sheetName || 'Sheet1') + worksheet.addRows(rows) + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { type: XLSX_MIME }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) +} diff --git a/lib/server/project-template/vue2/project-init-code/package.json b/lib/server/project-template/vue2/project-init-code/package.json index a52db45a6..1dfbfca11 100644 --- a/lib/server/project-template/vue2/project-init-code/package.json +++ b/lib/server/project-template/vue2/project-init-code/package.json @@ -108,7 +108,7 @@ "acorn": "~7.2.0", "ansi_up": "^5.0.0", "app-root-path": "~3.0.0", - "axios": "~1.12.0", + "axios": "^1.15.2", "babel-eslint": "~10.0.3", "babel-plugin-parameter-decorator": "^1.0.16", "better-npm-run": "~0.1.1", @@ -120,6 +120,7 @@ "cheerio": "1.0.0-rc.2", "chokidar": "~3.2.1", "co-views": "~2.1.0", + "ejs": "^3.1.10", "cookie": "~0.4.0", "core-js": "^3.25.5", "dayjs": "^1.9.3", @@ -138,7 +139,7 @@ "jsonp": "~0.2.1", "js-base64": "^3.7.7", "js-cookie": "3.0.1", - "koa": "~2.8.2", + "koa": "^2.16.4", "koa-body": "^4.2.0", "koa-bodyparser": "~4.2.1", "koa-convert": "~1.2.0", @@ -156,7 +157,7 @@ "node-request-context": "~1.0.5", "nodemon": "~1.19.3", "ora": "~4.0.2", - "path-to-regexp": "^8.3.0", + "path-to-regexp": "^8.4.2", "postcss": "~8.4.31", "postcss-import": "^15.0.0", "postcss-mixins": "^9.0.4", @@ -169,7 +170,6 @@ "query-string": "^7.1.1", "reflect-metadata": "^0.1.13", "shx": "~0.3.2", - "swig": "~1.4.2", "mavon-editor": "^2.9.0", "typeorm": "~0.3.27", "typescript": "^4.4.3", @@ -180,7 +180,7 @@ "vue-json-viewer": "^2.2.22", "vue-router": "~3.1.3", "vuex": "~3.1.1", - "xlsx": "^0.18.5" + "exceljs": "^4.4.0" }, "overrides": { "@babel/runtime": "7.23.9", diff --git a/lib/server/project-template/vue3/project-init-code/bk.config.js b/lib/server/project-template/vue3/project-init-code/bk.config.js index 0dd422f14..acd75aaa2 100644 --- a/lib/server/project-template/vue3/project-init-code/bk.config.js +++ b/lib/server/project-template/vue3/project-init-code/bk.config.js @@ -48,6 +48,19 @@ module.exports = { target: serverAddress } ] + }, + optimization: { + splitChunks: { + cacheGroups: { + exceljs: { + name: 'exceljs', + test: /[\\/]node_modules[\\/]exceljs[\\/]/, + priority: 2, + chunks: 'all', + reuseExistingChunk: true + } + } + } } } } diff --git a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js index 147f7d15f..fb8299d8d 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js +++ b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/data-manage-container/form-data-manage/components/buttons/index.js @@ -1,5 +1,5 @@ import { h, resolveComponent } from 'vue' -import * as XLSX from 'xlsx' +import { downloadXlsxFromAoa } from 'shared/excel' import './index.postcss' export default { @@ -62,10 +62,7 @@ export default { body.push(row) }) - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(wb, ws) - XLSX.writeFile(wb, `${this.tableName}.xlsx`) + await downloadXlsxFromAoa(`${this.tableName}.xlsx`, 'Sheet1', [header, ...body]) } catch (e) { console.log(e.message || e) } finally { diff --git a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue index 37eff329c..2f09af5ad 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue +++ b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/flow-form-comp/components/custom-buttons.vue @@ -24,7 +24,7 @@
> - import * as XLSX from 'xlsx' + import { downloadXlsxFromAoa } from 'shared/excel' export default { name: 'CustomButtons', @@ -100,10 +100,7 @@ body.push(row) }) - const wb = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(wb, ws) - XLSX.writeFile(wb, `${this.nodeName || this.tableName}.xlsx`) + await downloadXlsxFromAoa(`${this.nodeName || this.tableName}.xlsx`, 'Sheet1', [header, ...body]) } catch (e) { console.log(e.message || e) } finally { diff --git a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.js b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.js index bce14121d..1d4b91305 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.js +++ b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.js @@ -1,4 +1,5 @@ import { h, resolveComponent } from 'vue' +import './index.postcss' export default { name: 'bkform-engine-textarea', diff --git a/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.postcss b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.postcss new file mode 100644 index 000000000..7e3d41d85 --- /dev/null +++ b/lib/server/project-template/vue3/project-init-code/lib/client/src/components/form-engine/fields/textarea/index.postcss @@ -0,0 +1,8 @@ +.bkform-engine-field-widget { + .bk-lesscode-textarea, + .bk-textarea { + textarea { + flex: 1; + } + } +} \ No newline at end of file diff --git a/lib/server/project-template/vue3/project-init-code/lib/server/app.browser.js b/lib/server/project-template/vue3/project-init-code/lib/server/app.browser.js index be286d26f..af20e61ea 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/server/app.browser.js +++ b/lib/server/project-template/vue3/project-init-code/lib/server/app.browser.js @@ -99,7 +99,7 @@ async function startServer () { app.use(convert.compose(allowedMethods)) app.context.render = views(resolve(__dirname, '..', IS_DEV ? 'client' : 'client/dist'), { - map: { html: 'swig' } + map: { html: 'ejs' } }) app.use(historyApiFallback({ diff --git a/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js b/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js index c2f2a1aa3..cf6acd9b7 100644 --- a/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js +++ b/lib/server/project-template/vue3/project-init-code/lib/server/service/data-service.js @@ -249,9 +249,10 @@ export function getDataService (name = 'default', customEntityMap) { * @param {*} query 查询参数 * @returns 获取数据详情结果 */ - findOne (tableFileName, query = {}) { + async findOne (tableFileName, query = {}) { const repository = getRepositoryByName(tableFileName) - return repository.findOne({ where: transformQuery(query) }) || {} + const result = await repository.findOne({ where: transformQuery(query) }) + return result || {} }, /** diff --git a/lib/server/project-template/vue3/project-init-code/lib/shared/excel/index.js b/lib/server/project-template/vue3/project-init-code/lib/shared/excel/index.js new file mode 100644 index 000000000..a63f121c3 --- /dev/null +++ b/lib/server/project-template/vue3/project-init-code/lib/shared/excel/index.js @@ -0,0 +1,33 @@ +/** + * Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community Edition) available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +import ExcelJS from 'exceljs' + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +/** + * 浏览器端下载 xlsx + * @param {string} fileName + * @param {string} sheetName + * @param {Array>} rows + */ +export async function downloadXlsxFromAoa (fileName, sheetName, rows) { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet(sheetName || 'Sheet1') + worksheet.addRows(rows) + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { type: XLSX_MIME }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) +} diff --git a/lib/server/project-template/vue3/project-init-code/package.json b/lib/server/project-template/vue3/project-init-code/package.json index 82a726eb7..6346f3bd8 100644 --- a/lib/server/project-template/vue3/project-init-code/package.json +++ b/lib/server/project-template/vue3/project-init-code/package.json @@ -108,7 +108,7 @@ "acorn": "~7.2.0", "ansi_up": "^5.0.0", "app-root-path": "~3.0.0", - "axios": "~1.12.0", + "axios": "^1.15.2", "babel-eslint": "~10.0.3", "babel-plugin-parameter-decorator": "^1.0.16", "better-npm-run": "~0.1.1", @@ -119,6 +119,7 @@ "cheerio": "1.0.0-rc.2", "chokidar": "~3.2.1", "co-views": "~2.1.0", + "ejs": "^3.1.10", "cookie": "~0.4.0", "core-js": "^3.25.5", "dayjs": "^1.9.3", @@ -136,7 +137,7 @@ "jsonp": "~0.2.1", "js-base64": "^3.7.7", "js-cookie": "3.0.1", - "koa": "~2.8.2", + "koa": "^2.16.4", "koa-body": "^4.2.0", "koa-bodyparser": "~4.2.1", "koa-convert": "~1.2.0", @@ -154,7 +155,7 @@ "node-request-context": "~1.0.5", "nodemon": "~1.19.3", "ora": "~4.0.2", - "path-to-regexp": "^8.3.0", + "path-to-regexp": "^8.4.2", "postcss": "~8.4.31", "postcss-import": "^15.0.0", "postcss-mixins": "^9.0.4", @@ -167,7 +168,6 @@ "query-string": "^7.1.1", "reflect-metadata": "^0.1.13", "shx": "~0.3.2", - "swig": "~1.4.2", "mavon-editor": "^2.9.0", "typeorm": "~0.3.27", "typescript": "^4.4.3", @@ -178,7 +178,7 @@ "vue-json-viewer": "^2.2.22", "vue-router": "~4.1.6", "vuex": "^4.1.0", - "xlsx": "^0.18.5", + "exceljs": "^4.4.0", "less": "^4.2.0" }, "overrides": { diff --git a/lib/server/router/index.js b/lib/server/router/index.js index 732e3c2fd..b0cf6aa70 100644 --- a/lib/server/router/index.js +++ b/lib/server/router/index.js @@ -8,7 +8,6 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -import httpConf from '../conf/http' const KoaRouter = require('koa-router') const fs = require('fs') const path = require('path') @@ -23,7 +22,6 @@ function getRenderTemplateName (ctx) { } const renderParams = { STATIC_URL: '', - BK_API_URL_TMPL: httpConf.apiGateWayUrlTmpl, BKPAAS_BK_DOMAIN: process.env.BKPAAS_BK_DOMAIN, BK_SHARED_RES_URL: process.env.BKPAAS_SHARED_RES_URL, BK_COMPONENT_API_URL: process.env.BK_COMPONENT_API_URL diff --git a/lib/server/service/business/bk-base.js b/lib/server/service/business/bk-base.js index 93568ffb2..a60c39858 100644 --- a/lib/server/service/business/bk-base.js +++ b/lib/server/service/business/bk-base.js @@ -9,6 +9,7 @@ import { import { generateToken } from './token' +import { escapeIdentifier } from '../../../shared/security/sql-protection' const getProjectInfo = async (projectId, bkTicket) => { const projectInfo = await LCDataService.findOne( @@ -166,6 +167,20 @@ export const getTables = async (projectId, bkTicket, bkBizId) => { } } +/** + * 验证排序方向 + * @param {string} direction 排序方向 + * @returns {string} 验证后的排序方向 + */ +const validateSortDirection = (direction) => { + const allowedDirections = ['ASC', 'DESC'] + const upperDirection = String(direction).toUpperCase() + if (!allowedDirections.includes(upperDirection)) { + throw new Error('排序方向错误:只支持 ASC 或 DESC') + } + return upperDirection +} + /** * 获取有权限的数据 * @param {*} projectId 项目id @@ -175,43 +190,65 @@ export const getTables = async (projectId, bkTicket, bkBizId) => { * @returns 表数据 */ export const getTableDatas = async (projectId, queryData, tableFileName, bkTicket) => { - // 构造查询 SQL - const { - page, - pageSize, - bkSortKey, - bkSortValue, - bkDataSourceType, - ...others - } = queryData - let sql = `SELECT * FROM \`${tableFileName}\`` - Object - .keys(others) - .forEach((key, index) => { - if (index <= 0) { - sql += ' WHERE' - } else { - sql += ' AND' + try { + // 构造查询 SQL - 使用参数化查询防止SQL注入 + const { + page, + pageSize, + bkSortKey, + bkSortValue, + bkDataSourceType, + ...others + } = queryData + + // 验证表名 + const safeTableName = escapeIdentifier(tableFileName) + let sql = `SELECT * FROM ${safeTableName}` + + Object + .keys(others) + .forEach((key, index) => { + if (index <= 0) { + sql += ' WHERE' + } else { + sql += ' AND' + } + sql += ` \`${key}\` LIKE '%${others[key]}%'` + }) + + // 构建ORDER BY子句 + if (bkSortKey && bkSortValue) { + const safeSortKey = escapeIdentifier(bkSortKey) + const safeSortDirection = validateSortDirection(bkSortValue) + sql += ` ORDER BY ${safeSortKey} ${safeSortDirection}` + } + + // 构建LIMIT子句 + if (page !== undefined && pageSize !== undefined) { + const pageNum = parseInt(page, 10) + const pageSizeNum = parseInt(pageSize, 10) + + // 验证分页参数 + if (isNaN(pageNum) || isNaN(pageSizeNum) || pageNum < 0 || pageSizeNum <= 0 || pageSizeNum> 1000) { + throw new Error('分页参数错误:页码不能为负数,每页大小必须在1-1000之间') } - sql += ` \`${key}\` LIKE '%${others[key]}%'` - }) - if (bkSortKey && bkSortValue) { - sql += ` ORDER BY \`${bkSortKey}\` ${bkSortValue}` - } - if (page && pageSize) { - const index = page * pageSize - sql += ` LIMIT ${index}, ${pageSize}` - } + + const offset = pageNum * pageSizeNum + sql += ` LIMIT ${offset}, ${pageSizeNum}` + } - sql += ';' - // 执行查询 - const multTableDatas = await execSQL(projectId, sql, bkTicket) - // 过滤 bk-base 系统内置字段 - const list = (multTableDatas?.[0] || []).map((item) => { - const { dtEventTimeStamp, localTime, thedate, dtEventTime, ...rest } = item - return rest - }) - return { - list + sql += ';' + // 执行查询 + const multTableDatas = await execSQL(projectId, sql, bkTicket) + // 过滤 bk-base 系统内置字段 + const list = (multTableDatas?.[0] || []).map((item) => { + const { dtEventTimeStamp, localTime, thedate, dtEventTime, ...rest } = item + return rest + }) + return { + list + } + } catch (error) { + throw new Error(error.message || error) } } diff --git a/lib/server/service/business/data-source.js b/lib/server/service/business/data-source.js index 6d6e18021..7b2ae19a4 100644 --- a/lib/server/service/business/data-source.js +++ b/lib/server/service/business/data-source.js @@ -108,11 +108,19 @@ export const getSqlByCondition = async (projectId, condition) => { return generateSqlByCondition(condition, list) } -// 只能是查询语句检查 +// 只能是查询语句检查 - 增强版本 export const querySqlCheck = (sql) => { - if (!/;$/.test(sql.trim())) { + if (!sql || typeof sql !== 'string') { + throw new Error('SQL语句不能为空') + } + + const trimmedSql = sql.trim() + + if (!/;$/.test(trimmedSql)) { throw new Error(global.i18n.t('Sql 语句不完整,需要是【;】号结尾')) } + + // 基础检查列表(保持原有逻辑) const checkList = [ { check: (val) => !/^select/.test(val), message: global.i18n.t('仅支持 SELECT 查询语句,请修改后再试') }, { check: (val) => /database\(\)/i.test(val), message: global.i18n.t('不允许出现 database() 函数') }, @@ -123,28 +131,75 @@ export const querySqlCheck = (sql) => { { check: (val) => /MYSQL\./i.test(val), message: global.i18n.t('不允许查询 MYSQL 内置表相关数据') }, { check: (val) => /\@\@/i.test(val), message: global.i18n.t('不允许出现@@符号') } ] + + // 增强的安全检查列表 + const enhancedCheckList = [ + { check: (val) => /union\s+select/i.test(val), message: global.i18n.t('不允许使用 UNION SELECT 语句') }, + { check: (val) => /;\s*(drop|delete|update|insert|alter|create|grant|revoke)/i.test(val), message: global.i18n.t('检测到多语句注入尝试') }, + { check: (val) => /--/.test(val), message: global.i18n.t('不允许使用 SQL 注释符号 --') }, + { check: (val) => /\/\*/.test(val), message: global.i18n.t('不允许使用多行注释 /*') }, + { check: (val) => /\*\//.test(val), message: global.i18n.t('不允许使用多行注释 */') }, + { check: (val) => /xp_cmdshell/i.test(val), message: global.i18n.t('不允许使用 xp_cmdshell 函数') }, + { check: (val) => /sp_executesql/i.test(val), message: global.i18n.t('不允许使用 sp_executesql 函数') }, + { check: (val) => /exec\s*\(/i.test(val), message: global.i18n.t('不允许使用 EXEC 函数') }, + { check: (val) => /execute\s*\(/i.test(val), message: global.i18n.t('不允许使用 EXECUTE 函数') }, + { check: (val) => /load_file\s*\(/i.test(val), message: global.i18n.t('不允许使用 LOAD_FILE 函数') }, + { check: (val) => /into\s+outfile/i.test(val), message: global.i18n.t('不允许使用 INTO OUTFILE 语句') }, + { check: (val) => /into\s+dumpfile/i.test(val), message: global.i18n.t('不允许使用 INTO DUMPFILE 语句') }, + { check: (val) => /benchmark\s*\(/i.test(val), message: global.i18n.t('不允许使用 BENCHMARK 函数') }, + { check: (val) => /sleep\s*\(/i.test(val), message: global.i18n.t('不允许使用 SLEEP 函数') }, + { check: (val) => /waitfor\s+delay/i.test(val), message: global.i18n.t('不允许使用 WAITFOR DELAY 语句') }, + { check: (val) => /pg_sleep\s*\(/i.test(val), message: global.i18n.t('不允许使用 PG_SLEEP 函数') }, + { check: (val) => /extractvalue\s*\(/i.test(val), message: global.i18n.t('不允许使用 EXTRACTVALUE 函数') }, + { check: (val) => /updatexml\s*\(/i.test(val), message: global.i18n.t('不允许使用 UPDATEXML 函数') }, + { check: (val) => /name_const\s*\(/i.test(val), message: global.i18n.t('不允许使用 NAME_CONST 函数') }, + { check: (val) => /multipoint\s*\(/i.test(val), message: global.i18n.t('不允许使用 MULTIPOINT 函数') }, + { check: (val) => /polygon\s*\(/i.test(val), message: global.i18n.t('不允许使用 POLYGON 函数') }, + { check: (val) => /multipolygon\s*\(/i.test(val), message: global.i18n.t('不允许使用 MULTIPOLYGON 函数') }, + { check: (val) => /linestring\s*\(/i.test(val), message: global.i18n.t('不允许使用 LINESTRING 函数') }, + { check: (val) => /multilinestring\s*\(/i.test(val), message: global.i18n.t('不允许使用 MULTILINESTRING 函数') } + ] + const sqlArr = splitSql(sql) sqlArr.forEach((sqlStr) => { const lowerCaseSql = sqlStr.trim().toLowerCase() if (lowerCaseSql) { + // 执行原有检查 checkList.forEach((checkItem) => { if (checkItem.check(lowerCaseSql)) { throw new Error(checkItem.message) } }) + + // 执行增强检查 + enhancedCheckList.forEach((checkItem) => { + if (checkItem.check(lowerCaseSql)) { + throw new Error(checkItem.message) + } + }) } }) } // sql 解码 export const decodeSql = (sql, params) => { - let decodedSql = decodeBase64(sql) - const paramKeys = Object.keys(params || {}) - if (paramKeys.length> 0) { - paramKeys.forEach((paramKey) => { + try { + let decodedSql = decodeBase64(sql) + + // 验证解码后的SQL + if (!decodedSql || typeof decodedSql !== 'string') { + throw new Error('无效的SQL语句') + } + + const paramKeys = Object.keys(params || {}) + if (paramKeys.length> 0) { + paramKeys.forEach((paramKey) => { const reg = new RegExp(`\\$\\{${paramKey}\\}`, 'g') decodedSql = decodedSql.replace(reg, params[paramKey]) - }) + }) + } + return decodedSql + } catch (error) { + throw new Error(`SQL解码失败: ${error.message}`) } - return decodedSql } diff --git a/lib/server/service/business/open-api.js b/lib/server/service/business/open-api.js index 355d05bb5..b9f3bf12e 100644 --- a/lib/server/service/business/open-api.js +++ b/lib/server/service/business/open-api.js @@ -9,6 +9,7 @@ import { execApiGateWay, decodeToken } from '@bkui/apigateway-nodejs-sdk' +import httpConf from '../../conf/http' import openApiJson from '../../system-conf/open-api.json' import { transformVersionToNum @@ -106,7 +107,7 @@ export const generateApiGateway = async () => { // 更新资源文档 await execApiGateWay({ apiName: 'bk-apigateway', - apiUrlTemp: process.env.BK_API_URL_TMPL, + apiUrlTemp: httpConf.apiGateWayUrlTmpl, path: `/api/v1/apis/${apiName}/resource-docs/import/by-archive/`, method: 'post', stageName: 'prod', diff --git a/lib/server/service/business/preview-db-service.js b/lib/server/service/business/preview-db-service.js index da2764d63..f307f1844 100644 --- a/lib/server/service/business/preview-db-service.js +++ b/lib/server/service/business/preview-db-service.js @@ -10,7 +10,7 @@ */ import DBEngineService from '../common/db-engine-service' -import { LCDataService, TABLE_FILE_NAME, getDataService } from '../common/data-service' +import { LCDataService, TABLE_FILE_NAME, getDataService } from '../common/data-service' import { EntitySchema, createConnection, EventSubscriber, Like } from 'typeorm' import { RequestContext } from '../../middleware/request-context' import { sm4Encrypt, sm4Decrypt, uuid } from '../../util' @@ -19,16 +19,34 @@ import OnlineDBService from '../common/online-db-service' import { transferTimeByTimezoneOffset } from './data-source' +import { escapeIdentifier } from '../../../shared/security/sql-protection' +const mysql = require('mysql2') const dataBaseConf = require('../../conf/data-source') /** * 开启预览 * @param {*} projectId 应用id + * @param {*} dbName 数据库名称 */ export const enablePerviewDb = async (projectId, dbName) => { + // 输入验证 + if (!dbName || typeof dbName !== 'string') { + throw new Error('数据库名称必须是非空字符串') + } + + // 标识符格式校验 + try { + escapeIdentifier(dbName) + } catch (error) { + throw new Error('数据库名称格式错误:只允许字母、数字、下划线和连字符') + } + + // 安全的数据库名称 + const safeDbName = `bklesscode_${dbName}` + const dbInfo = { projectId, - dbName: `bklesscode_${dbName}`, + dbName: safeDbName, userName: uuid(), passWord: uuid() } @@ -36,12 +54,20 @@ export const enablePerviewDb = async (projectId, dbName) => { // 创建用于预览的DB const previewDbEngine = await getPreviewDbEngine() await previewDbEngine.execCb(async (pool) => { + // 使用 mysql.escapeId 对数据库名进行安全转义 + const escapedDbName = mysql.escapeId(dbInfo.dbName) + // 创建应用对应的预览数据库 - await pool.query(`CREATE DATABASE \`${dbInfo.dbName}\`;`) - // 创建用户并授权对应的库 - await pool.query(`CREATE USER '${dbInfo.userName}'@'%' IDENTIFIED BY '${dbInfo.passWord}';`) - await pool.query(`GRANT ALL ON ${dbInfo.dbName}.* TO '${dbInfo.userName}'@'%';`) - await pool.query('FLUSH PRIVILEGES;') + await pool.query(`CREATE DATABASE ${escapedDbName}`) + + // 创建用户并授权对应的库 - 使用 mysql.escape 转义所有值 + const escapedUserName = mysql.escape(dbInfo.userName) + const escapedPassword = mysql.escape(dbInfo.passWord) + const escapedHost = mysql.escape('%') + + await pool.query(`CREATE USER ${escapedUserName}@${escapedHost} IDENTIFIED BY ${escapedPassword}`) + await pool.query(`GRANT ALL ON ${escapedDbName}.* TO ${escapedUserName}@${escapedHost}`) + await pool.query('FLUSH PRIVILEGES') }) // 加密 @@ -317,7 +343,7 @@ export const getThirdPartDBTables = async (projectId, page, pageSize, thirdPartD BASE_COLUMNS()[0] ]) }) - updateSql += `ALTER TABLE \`${tableData.tableName}\` ADD COLUMN \`id\` int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY;` + updateSql += `ALTER TABLE ${mysql.escapeId(tableData.tableName)} ADD COLUMN ${mysql.escapeId('id')} int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY;` } } }) diff --git a/lib/server/service/business/v3-service.js b/lib/server/service/business/v3-service.js index e38e9c991..b6522e9b7 100644 --- a/lib/server/service/business/v3-service.js +++ b/lib/server/service/business/v3-service.js @@ -228,8 +228,7 @@ export const uploadToBkRepo = async (projectId, version, versionId, releaseId) = return new Promise(async (resolve, reject) => { try { shell.cd(STATIC_URL) - const targz = `tar zcf bklesscode-proj-${projectId}.tar.gz -C ./${sourceDir} .` - shell.exec(targz) + shell.cmd('tar', ['zcf', `bklesscode-proj-${projectId}.tar.gz`, '-C', `./${sourceDir}`, '.']) shell.cd(projectPath) // 根据压缩包大小计算md5值 diff --git a/lib/server/service/common/data-service.js b/lib/server/service/common/data-service.js index 03e8d10ab..9d5654ed9 100644 --- a/lib/server/service/common/data-service.js +++ b/lib/server/service/common/data-service.js @@ -312,9 +312,10 @@ export function getDataService (name = 'default', customEntityMap) { * @param {*} query 查询参数 * @returns 获取数据详情结果 */ - findOne (tableFileName, query = { deleteFlag: 0 }) { + async findOne (tableFileName, query = { deleteFlag: 0 }) { const repository = getRepositoryByName(tableFileName) - return repository.findOne({ where: transformQuery(query) }) || {} + const result = await repository.findOne({ where: transformQuery(query) }) + return result || {} }, /** * 添加 diff --git a/lib/shared/data-source/data-parse/data-parser/xlsx-parser.js b/lib/shared/data-source/data-parse/data-parser/xlsx-parser.js index 9c7596489..67b78c221 100644 --- a/lib/shared/data-source/data-parse/data-parser/xlsx-parser.js +++ b/lib/shared/data-source/data-parse/data-parser/xlsx-parser.js @@ -8,7 +8,10 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -import * as XLSX from 'xlsx' +import { + buildXlsxBufferFromAoa, + parseFirstSheetToJson +} from '../../../excel' import { getTypeByValue } from '../../../util' import { DATA_TYPES } from '../../../constant' @@ -17,20 +20,18 @@ import { DATA_TYPES } from '../../../constant' * @param {*} xlsxs xlsx 文件 * @returns 数据 json */ -function transformXlsx2Json (xlsxs) { - return xlsxs.map(({ content }) => { - const workBook = XLSX.read(content, { type: 'binary', cellDates: true }) - // 读取第一个 sheet - return XLSX.utils.sheet_to_json(workBook.Sheets[workBook.SheetNames[0]], { raw: false }) - }) +async function transformXlsx2Json (xlsxs) { + const result = [] + for (const { content } of xlsxs) { + result.push(await parseFirstSheetToJson(content, { raw: false, cellDates: true })) + } + return result } -function transformJson2Xlsx (finalDatas) { - return finalDatas.map(({ tableName, list }) => { - // 生成表头 +async function transformJson2Xlsx (finalDatas) { + const result = [] + for (const { tableName, list } of finalDatas) { const header = Object.keys(list[0]) - - // 生成字段值 const body = [] list.forEach((data) => { const dataValues = header.map((key) => { @@ -42,15 +43,10 @@ function transformJson2Xlsx (finalDatas) { }) body.push(dataValues) }) - - // 构造 xlsx 文件 - const workBook = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(workBook, ws, tableName) - const content = XLSX.write(workBook, { bookType: 'xlsx', bookSST: false, type: 'buffer' }) - - return { tableName, content } - }) + const content = await buildXlsxBufferFromAoa(tableName, [header, ...body]) + result.push({ tableName, content }) + } + return result } /** @@ -61,12 +57,12 @@ export class DataXlsxParser { this.xlsxs = xlsxs } - set (that = {}) { - that.finalDatas = transformXlsx2Json(this.xlsxs) + async set (that = {}) { + that.finalDatas = await transformXlsx2Json(this.xlsxs) return that } - export (that) { + async export (that) { return transformJson2Xlsx(that.finalDatas) } } diff --git a/lib/shared/data-source/data-parse/index.js b/lib/shared/data-source/data-parse/index.js index 8aa19e9c6..e9abf7cd4 100644 --- a/lib/shared/data-source/data-parse/index.js +++ b/lib/shared/data-source/data-parse/index.js @@ -49,7 +49,7 @@ export class DataParse { * @param {*} parser 具体执行导出的实例 * @returns 返回导出结果 */ - export (parser) { + async export (parser) { return parser.export(this) } } diff --git a/lib/shared/data-source/data-parse/struct-parser/xlsx-parser.js b/lib/shared/data-source/data-parse/struct-parser/xlsx-parser.js index a7c15d627..6a4d5e48d 100644 --- a/lib/shared/data-source/data-parse/struct-parser/xlsx-parser.js +++ b/lib/shared/data-source/data-parse/struct-parser/xlsx-parser.js @@ -8,7 +8,10 @@ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the * specific language governing permissions and limitations under the License. */ -import * as XLSX from 'xlsx' +import { + buildXlsxBufferFromAoa, + parseFirstSheetToJson +} from '../../../excel' import { ORM_KEYS } from '../../constant' @@ -18,16 +21,16 @@ import { * @param {[{ tableName, content }]} xlsxs xlsx 数据 * @returns [{ tableName: 表名, columns: 列信息 }] */ -function transformXlsx2Json (xlsxs) { - return xlsxs.map(({ tableName, content }) => { - const workBook = XLSX.read(content, { type: 'binary', cellDates: true }) - // 读取第一个 sheet - const columns = XLSX.utils.sheet_to_json(workBook.Sheets[workBook.SheetNames[0]]) - return { +async function transformXlsx2Json (xlsxs) { + const result = [] + for (const { tableName, content } of xlsxs) { + const columns = await parseFirstSheetToJson(content, { cellDates: true }) + result.push({ tableName, columns - } - }) + }) + } + return result } /** @@ -35,12 +38,10 @@ function transformXlsx2Json (xlsxs) { * @param {*} finalDatas table json * @returns [{ tableName: 表名, content: 文件内容 }] */ -function transformJson2Xlsx (finalDatas) { - return finalDatas.map(({ tableName, columns }) => { - // 生成表头 +async function transformJson2Xlsx (finalDatas) { + const result = [] + for (const { tableName, columns } of finalDatas) { const header = ORM_KEYS.filter(x => x !== 'columnId') - - // 生成字段值 const body = [] columns.forEach((column) => { const columnValue = header.map((key) => { @@ -48,15 +49,10 @@ function transformJson2Xlsx (finalDatas) { }) body.push(columnValue) }) - - // 构造 xlsx 文件 - const workBook = XLSX.utils.book_new() - const ws = XLSX.utils.aoa_to_sheet([header, ...body]) - XLSX.utils.book_append_sheet(workBook, ws, tableName) - const content = XLSX.write(workBook, { bookType: 'xlsx', bookSST: false, type: 'buffer' }) - - return { tableName, content } - }) + const content = await buildXlsxBufferFromAoa(tableName, [header, ...body]) + result.push({ tableName, content }) + } + return result } /** @@ -67,12 +63,12 @@ export class StructXlsxParser { this.xlsxs = xlsxs } - set (that = {}) { - that.finalDatas = transformXlsx2Json(this.xlsxs) + async set (that = {}) { + that.finalDatas = await transformXlsx2Json(this.xlsxs) return that } - export (that) { + async export (that) { return transformJson2Xlsx(that.finalDatas) } } diff --git a/lib/shared/data-source/helper.js b/lib/shared/data-source/helper.js index 889879669..8ba6349dd 100644 --- a/lib/shared/data-source/helper.js +++ b/lib/shared/data-source/helper.js @@ -45,16 +45,16 @@ import { * @param {*} name 导出文件名 * @returns 返回导出内容 [{ name, conent }] */ -export const generateExportStruct = (tables, fileType, name) => { +export const generateExportStruct = async (tables, fileType, name) => { const dataParse = new DataParse() const structJsonParser = new StructJsonParser(tables) if (fileType === DATA_FILE_TYPE.SQL) { const structSqlParser = new StructSqlParser() - const content = dataParse.import(structJsonParser).export(structSqlParser) + const content = await dataParse.import(structJsonParser).export(structSqlParser) return [{ content, name }] } else { const structXlsxParser = new StructXlsxParser() - const fileContents = dataParse.import(structJsonParser).export(structXlsxParser) + const fileContents = await dataParse.import(structJsonParser).export(structXlsxParser) return fileContents.map(({ tableName, content }) => { return { name: name || `${tableName}.xlsx`, @@ -71,16 +71,16 @@ export const generateExportStruct = (tables, fileType, name) => { * @param {*} name 导出文件名 * @returns 返回导出内容 [{ name, conent }] */ -export const generateExportDatas = (datas, fileType, name) => { +export const generateExportDatas = async (datas, fileType, name) => { const dataParse = new DataParse() const dataJsonParser = new DataJsonParser(datas) if (fileType === DATA_FILE_TYPE.SQL) { const dataSqlParser = new DataSqlParser() - const content = dataParse.import(dataJsonParser).export(dataSqlParser) + const content = await dataParse.import(dataJsonParser).export(dataSqlParser) return [{ content, name }] } else { const dataXlsxParser = new DataXlsxParser() - const fileContents = dataParse.import(dataJsonParser).export(dataXlsxParser) + const fileContents = await dataParse.import(dataJsonParser).export(dataXlsxParser) return fileContents.map(({ tableName, content }) => { return { name: name || `${tableName}.xlsx`, @@ -96,18 +96,16 @@ export const generateExportDatas = (datas, fileType, name) => { * @param {*} fileType 文件类型 * @returns 表结构 json */ -export const handleImportStruct = (files, fileType) => { +export const handleImportStruct = async (files, fileType) => { const dataParse = new DataParse() const structJsonParser = new StructJsonParser() let tableStructs = [] if (fileType === DATA_FILE_TYPE.SQL) { - tableStructs = dataParse - .set(new StructSqlParser(files)) - .export(structJsonParser) + await dataParse.set(new StructSqlParser(files)) + tableStructs = await dataParse.export(structJsonParser) } else { - tableStructs = dataParse - .set(new StructXlsxParser(files)) - .export(structJsonParser) + await dataParse.set(new StructXlsxParser(files)) + tableStructs = await dataParse.export(structJsonParser) } // 执行校验 checkTable(tableStructs) @@ -121,7 +119,7 @@ export const handleImportStruct = (files, fileType) => { * @param {*} columns 字段信息 * @returns 数据 json or sql */ -export const handleImportData = (files, fileType, columns) => { +export const handleImportData = async (files, fileType, columns) => { const dataParse = new DataParse() const dataJsonParser = new DataJsonParser() if (fileType === DATA_FILE_TYPE.SQL) { @@ -136,7 +134,8 @@ export const handleImportData = (files, fileType, columns) => { return files } else { const dataXlsxParser = new DataXlsxParser(files) - return dataParse.set(dataXlsxParser).export(dataJsonParser) + await dataParse.set(dataXlsxParser) + return dataParse.export(dataJsonParser) } } diff --git a/lib/shared/excel/index.js b/lib/shared/excel/index.js new file mode 100644 index 000000000..91886b87f --- /dev/null +++ b/lib/shared/excel/index.js @@ -0,0 +1,138 @@ +/** + * Tencent is pleased to support the open source community by making 蓝鲸智云PaaS平台社区版 (BlueKing PaaS Community Edition) available. + * Copyright (C) 2025 Tencent. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +import ExcelJS from 'exceljs' + +const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + +function binaryStringToUint8Array (content) { + const bytes = new Uint8Array(content.length) + for (let i = 0; i < content.length; i++) { + bytes[i] = content.charCodeAt(i) & 0xff + } + return bytes +} + +function normalizeLoadInput (content) { + if (typeof content === 'string') { + return binaryStringToUint8Array(content) + } + return content +} + +function getCellValue (cell, { raw = false } = {}) { + if (!cell) { + return '' + } + const { value } = cell + if (value === null || value === undefined) { + return '' + } + if (!raw) { + if (cell.text !== undefined && cell.text !== null && cell.text !== '') { + return cell.text + } + if (value instanceof Date) { + return value + } + if (typeof value === 'object') { + if (value.text !== undefined) { + return value.text + } + if (value.result !== undefined) { + return value.result + } + if (value.richText) { + return value.richText.map(item => item.text).join('') + } + } + return value + } + if (typeof value === 'object' && value.result !== undefined) { + return value.result + } + return value +} + +/** + * 解析 xlsx 第一个 sheet 为对象数组(首行作为表头) + * @param {string|ArrayBuffer|Uint8Array} content + * @param {{ raw?: boolean, cellDates?: boolean }} options + */ +export async function parseFirstSheetToJson (content, options = {}) { + const { raw = false } = options + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(normalizeLoadInput(content)) + const worksheet = workbook.worksheets[0] + if (!worksheet) { + return [] + } + + const headerMap = {} + const headerRow = worksheet.getRow(1) + headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => { + const header = getCellValue(cell, { raw }) + if (header !== '' && header !== undefined && header !== null) { + headerMap[colNumber] = header + } + }) + + const result = [] + const rowCount = worksheet.rowCount + for (let rowNumber = 2; rowNumber <= rowCount; rowNumber++) { + const row = worksheet.getRow(rowNumber) + const record = {} + let hasValue = false + row.eachCell({ includeEmpty: true }, (cell, colNumber) => { + const key = headerMap[colNumber] + if (key === undefined) { + return + } + const cellValue = getCellValue(cell, { raw }) + if (cellValue !== '' && cellValue !== undefined && cellValue !== null) { + hasValue = true + } + record[key] = cellValue + }) + if (hasValue) { + result.push(record) + } + } + return result +} + +/** + * 由二维数组生成 xlsx Buffer + * @param {string} sheetName + * @param {Array>} rows + */ +export async function buildXlsxBufferFromAoa (sheetName, rows) { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet(sheetName || 'Sheet1') + worksheet.addRows(rows) + return workbook.xlsx.writeBuffer() +} + +/** + * 浏览器端下载 xlsx + * @param {string} fileName + * @param {string} sheetName + * @param {Array>} rows + */ +export async function downloadXlsxFromAoa (fileName, sheetName, rows) { + const buffer = await buildXlsxBufferFromAoa(sheetName, rows) + const blob = new Blob([buffer], { type: XLSX_MIME }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) +} diff --git a/lib/shared/security/sql-protection.js b/lib/shared/security/sql-protection.js new file mode 100644 index 000000000..6d6558da9 --- /dev/null +++ b/lib/shared/security/sql-protection.js @@ -0,0 +1,12 @@ +/** + * 安全的SQL标识符转义函数 + * @param {string} identifier 标识符(表名、列名等) + * @returns {string} 转义后的标识符 + */ +export const escapeIdentifier = (identifier) => { + // 只允许字母、数字、下划线 + if (!/^[a-zA-Z0-9_-]+$/.test(identifier)) { + throw new Error('标识符格式错误:只允许字母、数字、下划线和连字符') + } + return `\`${identifier}\`` +} diff --git a/lib/shared/security/xss-protection.js b/lib/shared/security/xss-protection.js new file mode 100644 index 000000000..2a1990c79 --- /dev/null +++ b/lib/shared/security/xss-protection.js @@ -0,0 +1,41 @@ +/** + * XSS 防护工具函数 + * 提供统一的XSS过滤和防护功能 + **/ +/** + * HTML实体编码映射表 + */ +const HTML_ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '/': '/' +} + +/** + * 转义HTML特殊字符,防止XSS攻击 + * @param {string} str 需要转义的字符串 + * @returns {string} 转义后的安全字符串 + */ +export function escapeHtml (str) { + if (typeof str !== 'string') { + return str + } + return str.replace(/[&"'/]/g, (match) => HTML_ENTITIES[match]) +} + +/** + * 安全的JSON解析 + * @param {string} jsonStr JSON字符串 + * @param {*} defaultValue 默认值 + * @returns {*} 解析后的对象或默认值 + */ +export function safeJsonParse (jsonStr, defaultValue = null) { + try { + return JSON.parse(jsonStr) + } catch (e) { + return defaultValue + } +} diff --git a/package.json b/package.json index 8d5677336..2072a6091 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "app-root-path": "~3.0.0", "async-validator": "~1.8.1", "bk-lesscode-render": "1.0.0-beta.4", - "axios": "~1.12.0", + "axios": "^1.15.2", "babel-plugin-lodash": "^3.3.4", "babel-plugin-parameter-decorator": "^1.0.16", "better-npm-run": "~0.1.1", @@ -115,7 +115,8 @@ "change-case": "~4.1.1", "cheerio": "1.0.0-rc.2", "co-views": "~2.1.0", - "compressing": "^1.6.3", + "ejs": "^3.1.10", + "compressing": "^1.10.5", "continuation-local-storage": "~3.2.1", "cookie": "~0.4.0", "core-js": "^3.25.5", @@ -147,7 +148,7 @@ "js-base64": "^3.7.7", "js-cookie": "3.0.1", "jsdom": "~16.7.0", - "koa": "~2.8.2", + "koa": "^2.16.4", "koa-body": "^4.2.0", "koa-bodyparser": "~4.2.1", "koa-convert": "~1.2.0", @@ -162,7 +163,7 @@ "koa-static": "~5.0.0", "koa-unless": "^1.0.7", "koa2-connect-history-api-fallback": "~0.1.2", - "lodash": "~4.17.15", + "lodash": "^4.18.0", "lodash.clonedeep": "~4.5.0", "log4js-json-layout": "^2.2.2", "lucky-canvas": "^1.7.27", @@ -177,15 +178,15 @@ "mavon-editor": "^2.9.0", "md5": "^2.3.0", "monaco-editor": "^0.52.2", - "monaco-editor-webpack-plugin":"^7.1.0", - "moment": "^2.29.1", + "monaco-editor-webpack-plugin": "^7.1.0", + "moment": "^2.30.1", "mysql2": "~2.3.3", "node-request-context": "~1.0.5", "node-sql-parser": "^4.5.1", "nodemon": "~1.19.3", "node-fetch": "^2.6.13", "npm": "6.14.15", - "path-to-regexp": "^8.3.0", + "path-to-regexp": "^8.4.2", "pinyin": "^2.11.2", "postcss": "~8.4.31", "postcss-import": "^15.0.0", @@ -200,11 +201,10 @@ "raw-loader": "^4.0.2", "reflect-metadata": "^0.1.13", "screenfull": "~5.0.2", - "shelljs": "~0.8.4", + "shelljs": "^0.10.0", "shx": "~0.3.2", "swagger-ui-dist": "^5.11.7", - "swig": "~1.4.2", - "swiper": "11.0.5", + "swiper": "^12.1.2", "swiper-element-animation": "^1.1.0", "transliteration": "~2.1.8", "tslib": "^2.0.3", @@ -226,7 +226,7 @@ "vue-template-compiler": "~2.7.14", "vuedraggable": "~2.23.2", "vuex": "~3.1.1", - "xlsx": "^0.18.5" + "exceljs": "^4.4.0" }, "overrides": { "@babel/runtime": "7.23.9",

AltStyle によって変換されたページ (->オリジナル) /