Commit ed210527 by niuxing

初始化报表工具

1 parent 6d624c29
......@@ -11,7 +11,7 @@ import schedulerRouter from './routes/scheduler.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
const PORT = 4000
const HOSTNAME = "0.0.0.0"
const HOSTNAME = "localhost"
// 中间件
app.use(cors())
......
import { Router } from 'express'
import { v4 as uuidv4 } from 'uuid'
import { executeQuery } from '../db.js'
import { analyzeSql } from '../sql-analyzer.js'
import { analyzeSql, validateSelectOnly } from '../sql-analyzer.js'
import { setCache, getCache, searchInCache } from '../cache.js'
const router = Router()
......@@ -16,6 +16,13 @@ router.post('/check', async (req, res) => {
if (!connId || !sql) {
return res.json({ success: false, message: '缺少参数' })
}
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
const result = await analyzeSql(connId, sql)
res.json({ success: true, data: result })
} catch (e) {
......@@ -34,6 +41,12 @@ router.post('/execute', async (req, res) => {
return res.json({ success: false, message: '缺少参数' })
}
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
const startTime = Date.now()
const data = await executeQuery(connId, sql)
const duration = Date.now() - startTime
......
......@@ -9,6 +9,7 @@ import { startScriptTask, stopScriptTask, reloadAllScriptTasks, executeScriptTas
import { getPool } from '../db.js'
import { buildSqlWithDateRange } from '../condition-builder.js'
import { sendReportMail, loadSmtpConfig } from '../mail.js'
import { validateSelectOnly } from '../sql-analyzer.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json')
......@@ -60,6 +61,12 @@ router.post('/', (req, res) => {
return res.json({ success: false, message: '脚本名称和SQL内容不能为空' })
}
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
const scripts = loadScripts()
const id = `script_${Date.now()}`
const now = new Date().toISOString()
......@@ -114,6 +121,15 @@ router.put('/:id', (req, res) => {
return res.json({ success: false, message: '脚本不存在' })
}
// 校验SQL必须为SELECT语句(仅当sql被更新时校验)
const newSql = sql !== undefined ? sql : scripts[idx].sql
if (newSql) {
const sqlCheck = validateSelectOnly(newSql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
}
// 先停止旧的定时任务
stopScriptTask(id)
......@@ -168,6 +184,13 @@ router.post('/:id/run', async (req, res) => {
const scripts = loadScripts()
const script = scripts.find(s => s.id === req.params.id)
if (!script) return res.json({ success: false, message: '脚本不存在' })
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(script.sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
executeScriptTask(script)
res.json({ success: true, message: '任务已触发执行' })
} catch (e) {
......@@ -188,6 +211,12 @@ router.post('/:id/send-mail', async (req, res) => {
return res.json({ success: false, message: '请指定收件人邮箱' })
}
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(script.sql)
if (!sqlCheck.allowed) {
return res.json({ success: false, message: sqlCheck.reason })
}
// 验证SMTP配置
const smtpConfig = loadSmtpConfig()
if (!smtpConfig) {
......
......@@ -7,6 +7,7 @@ import xlsx from 'xlsx'
import { getPool } from './db.js'
import { buildSqlWithDateRange } from './condition-builder.js'
import { sendReportMail } from './mail.js'
import { validateSelectOnly } from './sql-analyzer.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SCRIPTS_FILE = path.join(__dirname, '..', 'sql-scripts.json')
......@@ -66,6 +67,14 @@ export async function executeScriptTask(script) {
console.log(`[定时任务] 开始执行: ${script.name} (${new Date().toLocaleString()})`)
try {
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnly(script.sql)
if (!sqlCheck.allowed) {
updateScriptStatus(script.id, 'failed', sqlCheck.reason)
console.error(`[定时任务] ${script.name}: SQL校验失败 - ${sqlCheck.reason}`)
return
}
// 1. 解析动态条件
const resolvedConditions = resolveDynamicValues(script.conditions || [])
......
......@@ -122,14 +122,132 @@ export async function analyzeSql(connId, sql) {
* 检测SQL类型
*/
function detectSqlType(sql) {
const trimmed = sql.trim().toUpperCase()
if (trimmed.startsWith('SELECT')) return 'SELECT'
if (trimmed.startsWith('INSERT')) return 'INSERT'
if (trimmed.startsWith('UPDATE')) return 'UPDATE'
if (trimmed.startsWith('DELETE')) return 'DELETE'
if (trimmed.startsWith('CREATE')) return 'CREATE'
if (trimmed.startsWith('ALTER')) return 'ALTER'
if (trimmed.startsWith('DROP')) return 'DROP'
if (trimmed.startsWith('TRUNCATE')) return 'TRUNCATE'
const normalized = normalizeSqlForDetection(sql)
if (/^\s*SELECT\b/i.test(normalized)) return 'SELECT'
if (/^\s*WITH\b/i.test(normalized)) return 'SELECT' // CTE + SELECT
if (/^\s*INSERT\b/i.test(normalized)) return 'INSERT'
if (/^\s*UPDATE\b/i.test(normalized)) return 'UPDATE'
if (/^\s*DELETE\b/i.test(normalized)) return 'DELETE'
if (/^\s*REPLACE\b/i.test(normalized)) return 'REPLACE'
if (/^\s*CREATE\b/i.test(normalized)) return 'CREATE'
if (/^\s*ALTER\b/i.test(normalized)) return 'ALTER'
if (/^\s*DROP\b/i.test(normalized)) return 'DROP'
if (/^\s*TRUNCATE\b/i.test(normalized)) return 'TRUNCATE'
if (/^\s*LOAD\s+DATA\b/i.test(normalized)) return 'LOAD_DATA'
if (/^\s*CALL\b/i.test(normalized)) return 'CALL'
if (/^\s*GRANT\b/i.test(normalized)) return 'GRANT'
if (/^\s*REVOKE\b/i.test(normalized)) return 'REVOKE'
return 'OTHER'
}
/**
* 去除SQL前缀注释和空白,用于类型检测
*/
function normalizeSqlForDetection(sql) {
// 去除单行注释 (-- 和 #)
let normalized = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ')
// 去除多行注释
normalized = normalized.replace(/\/\*[\s\S]*?\*\//g, ' ')
return normalized.trim()
}
/**
* 校验SQL是否为只读SELECT语句
* 支持检测多语句场景(分号分隔)
* @param {string} sql
* @returns {{ allowed: boolean, reason: string|null, detectedType: string }}
*/
export function validateSelectOnly(sql) {
if (!sql || !sql.trim()) {
return { allowed: false, reason: 'SQL内容不能为空', detectedType: 'EMPTY' }
}
// 分号分隔的多语句逐条检测
const statements = splitSqlStatements(sql)
for (let i = 0; i < statements.length; i++) {
const stmt = statements[i]
if (!stmt.trim()) continue
const type = detectSqlType(stmt)
if (type === 'SELECT') continue // SELECT允许
// 非SELECT语句,拒绝
const DANGEROUS_LABELS = {
'INSERT': 'INSERT(插入数据)',
'UPDATE': 'UPDATE(更新数据)',
'DELETE': 'DELETE(删除数据)',
'REPLACE': 'REPLACE(替换数据)',
'CREATE': 'CREATE(创建对象)',
'ALTER': 'ALTER(修改结构)',
'DROP': 'DROP(删除对象)',
'TRUNCATE': 'TRUNCATE(清空表)',
'LOAD_DATA': 'LOAD DATA(导入数据)',
'CALL': 'CALL(调用存储过程)',
'GRANT': 'GRANT(授权)',
'REVOKE': 'REVOKE(回收权限)',
'OTHER': '未知类型',
}
const label = DANGEROUS_LABELS[type] || type
return {
allowed: false,
reason: `脚本仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${label},不允许执行`,
detectedType: type,
}
}
return { allowed: true, reason: null, detectedType: 'SELECT' }
}
/**
* 按分号分割SQL语句(忽略引号内的分号)
*/
function splitSqlStatements(sql) {
const statements = []
let current = ''
let inSingleQuote = false
let inDoubleQuote = false
for (let i = 0; i < sql.length; i++) {
const ch = sql[i]
const next = sql[i + 1]
if (ch === "'" && !inDoubleQuote) {
if (inSingleQuote && next === "'") {
// 转义单引号 ''
current += ch + next
i++
continue
}
inSingleQuote = !inSingleQuote
current += ch
continue
}
if (ch === '"' && !inSingleQuote) {
if (inDoubleQuote && next === '"') {
current += ch + next
i++
continue
}
inDoubleQuote = !inDoubleQuote
current += ch
continue
}
if (ch === ';' && !inSingleQuote && !inDoubleQuote) {
statements.push(current)
current = ''
continue
}
current += ch
}
if (current.trim()) {
statements.push(current)
}
return statements
}
......@@ -24,6 +24,42 @@ import SmtpConfigModal from './components/SmtpConfigModal'
import ResultTable from './components/ResultTable'
import request from './utils/request'
// 前端SQL类型检测(与后端 sql-analyzer.js 保持一致)
const BLOCKED_SQL_TYPES = ['INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'LOAD DATA', 'CALL', 'GRANT', 'REVOKE']
function detectSqlTypeFrontend(sql) {
if (!sql || !sql.trim()) return { type: 'EMPTY', isSelect: true }
const cleaned = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').trim()
const upper = cleaned.toUpperCase()
if (/^\s*SELECT\b/.test(upper)) return { type: 'SELECT', isSelect: true }
if (/^\s*WITH\b/.test(upper)) return { type: 'SELECT', isSelect: true }
for (const kw of BLOCKED_SQL_TYPES) {
const regex = new RegExp(`^\\s*${kw.replace(/\s+/g, '\\s+')}\\b`, 'i')
if (regex.test(upper)) return { type: kw.replace(/\s+/g, '_'), isSelect: false, label: kw }
}
return { type: 'OTHER', isSelect: false, label: '未知类型' }
}
function validateSelectOnlyFrontend(sql) {
if (!sql || !sql.trim()) return { allowed: true, reason: null }
const stmts = sql.split(';').filter(s => s.trim())
for (let i = 0; i < stmts.length; i++) {
const det = detectSqlTypeFrontend(stmts[i])
if (!det.isSelect) {
return { allowed: false, reason: `仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${det.label},不允许执行` }
}
}
return { allowed: true, reason: null }
}
// 清空右侧查询结果
function clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration) {
setQueryId(null)
setResultColumns([])
setResultTotal(0)
setResultDuration(null)
}
export default function App() {
// 数据库连接状态
const [connId, setConnId] = useState(null)
......@@ -137,6 +173,15 @@ export default function App() {
}
const sql = buildFinalSql()
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnlyFrontend(sql)
if (!sqlCheck.allowed) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(sqlCheck.reason, sql, 'SQL校验')
return
}
setFinalSql(sql)
try {
......@@ -145,9 +190,11 @@ export default function App() {
setCheckResult(res.data)
setCheckModalOpen(true)
} else {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(res.message, sql, '预检查阶段')
}
} catch (e) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(e.message || '检查失败', sql, '预检查阶段')
}
}
......@@ -155,6 +202,15 @@ export default function App() {
// 确认执行
const handleConfirmExecute = async () => {
setCheckModalOpen(false)
// 校验SQL必须为SELECT语句(双保险)
const sqlCheck = validateSelectOnlyFrontend(finalSql)
if (!sqlCheck.allowed) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(sqlCheck.reason, finalSql, 'SQL校验')
return
}
setExecLoading(true)
try {
......@@ -173,9 +229,11 @@ export default function App() {
// 执行成功后自动同步参数到脚本
syncConditionsToScript()
} else {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(res.message, finalSql, '确认执行阶段')
}
} catch (e) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(e.message || '执行失败', finalSql, '确认执行阶段')
} finally {
setExecLoading(false)
......@@ -193,6 +251,15 @@ export default function App() {
}
const sql = buildFinalSql()
// 校验SQL必须为SELECT语句
const sqlCheck = validateSelectOnlyFrontend(sql)
if (!sqlCheck.allowed) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(sqlCheck.reason, sql, 'SQL校验')
return
}
setFinalSql(sql)
setExecLoading(true)
......@@ -212,9 +279,11 @@ export default function App() {
// 执行成功后自动同步参数到脚本
syncConditionsToScript()
} else {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(res.message, sql, '执行阶段')
}
} catch (e) {
clearQueryResults(setQueryId, setResultColumns, setResultTotal, setResultDuration)
showError(e.message || '执行失败', sql, '执行阶段')
} finally {
setExecLoading(false)
......
......@@ -8,7 +8,7 @@ import {
PlusOutlined, DeleteOutlined, EditOutlined, ImportOutlined,
SearchOutlined, SaveOutlined, MailOutlined, ClockCircleOutlined,
SettingOutlined, PlayCircleOutlined, CheckCircleOutlined,
CloseCircleOutlined, UploadOutlined,
CloseCircleOutlined, UploadOutlined, WarningOutlined,
} from '@ant-design/icons'
import dayjs from 'dayjs'
import request from '../utils/request'
......@@ -51,6 +51,36 @@ const validateEmails = (rule, value) => {
return Promise.resolve()
}
// 前端SQL类型检测(简化版,与后端 sql-analyzer.js 保持一致)
const BLOCKED_SQL_TYPES = ['INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'LOAD DATA', 'CALL', 'GRANT', 'REVOKE']
function detectSqlTypeFrontend(sql) {
if (!sql || !sql.trim()) return { type: 'EMPTY', isSelect: true, label: '' }
// 去除注释
const cleaned = sql.replace(/--[^\n]*/g, ' ').replace(/#[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').trim()
const upper = cleaned.toUpperCase()
if (/^\s*SELECT\b/.test(upper)) return { type: 'SELECT', isSelect: true, label: 'SELECT(查询)' }
if (/^\s*WITH\b/.test(upper)) return { type: 'SELECT', isSelect: true, label: 'CTE + SELECT(查询)' }
for (const kw of BLOCKED_SQL_TYPES) {
const regex = new RegExp(`^\\s*${kw.replace(/\s+/g, '\\s+')}\\b`, 'i')
if (regex.test(upper)) return { type: kw.replace(/\s+/g, '_'), isSelect: false, label: `${kw}` }
}
return { type: 'OTHER', isSelect: false, label: '未知类型' }
}
function validateSelectOnlyFrontend(sql) {
if (!sql || !sql.trim()) return { allowed: true, reason: null }
// 按分号分割(简化版)
const stmts = sql.split(';').filter(s => s.trim())
for (let i = 0; i < stmts.length; i++) {
const det = detectSqlTypeFrontend(stmts[i])
if (!det.isSelect) {
return { allowed: false, reason: `脚本仅允许SELECT查询语句,检测到第${i + 1}条语句为 ${det.label},不允许保存和执行` }
}
}
return { allowed: true, reason: null }
}
export default function SqlManagerModal({ open, onClose, onLoadSql, currentSql, currentConditions, connId, currentScriptId, onScriptIdChange }) {
const [scripts, setScripts] = useState([])
const [loading, setLoading] = useState(false)
......@@ -548,10 +578,27 @@ export default function SqlManagerModal({ open, onClose, onLoadSql, currentSql,
<Form.Item name="sql" label="SQL内容" rules={[{ required: true, message: 'SQL内容不能为空' }]}>
<TextArea
rows={6}
placeholder="SQL脚本内容"
placeholder="仅允许 SELECT 查询语句,不支持 INSERT/UPDATE/DELETE/DDL 等数据变更操作"
style={{ fontFamily: 'Menlo, Monaco, Consolas, monospace', fontSize: 12 }}
/>
</Form.Item>
<Form.Item shouldUpdate={(prev, cur) => prev.sql !== cur.sql} noStyle>
{({ getFieldValue }) => {
const sqlVal = getFieldValue('sql') || ''
const check = validateSelectOnlyFrontend(sqlVal)
if (sqlVal.trim() && !check.allowed) {
return (
<div style={{
padding: '6px 12px', marginBottom: 12, background: '#fff2f0',
border: '1px solid #ffccc7', borderRadius: 6, fontSize: 12, color: '#cf1322',
}}>
<WarningOutlined style={{ marginRight: 4 }} />{check.reason}
</div>
)
}
return null
}}
</Form.Item>
<div style={{ textAlign: 'right', marginBottom: 8 }}>
<Button size="small" icon={<SearchOutlined />} onClick={handleParseConditionsInDetail}>
从SQL解析参数
......
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!