@hedeqiang/dsh-nl2sql 0.12.1 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -1
- package/analysis.js +4 -1
- package/channels/dingtalk.js +11 -9
- package/channels/lark.js +17 -9
- package/channels/message.js +48 -12
- package/channels/telegram.js +13 -6
- package/channels/wecom.js +10 -9
- package/filters.js +87 -33
- package/index.js +4 -2
- package/lib/client.js +443 -148
- package/package.json +1 -1
- package/service/alerts.js +40 -20
- package/service/bots.js +38 -12
- package/service/channels.js +26 -5
- package/service/dashboards.js +5 -2
- package/service/datasources.js +16 -3
- package/service/query.js +36 -8
- package/service/uploads.js +4 -1
- package/sql-guard.js +33 -2
- package/xlsx.js +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hedeqiang/dsh-nl2sql",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.2",
|
|
4
4
|
"description": "Ask MySQL, PostgreSQL, Elasticsearch and CSV/Excel in plain language inside DeepSeek Harness: charts, dashboards, alerts, and IM push and bots for Lark/Feishu, Telegram, WeCom and DingTalk.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/service/alerts.js
CHANGED
|
@@ -6,6 +6,9 @@ import { evaluateAlert } from '../analysis.js'
|
|
|
6
6
|
import { admitSql } from '../sql-guard.js'
|
|
7
7
|
import { UserError, clampInt, newId, oneOf, requireText, upsert } from './shared.js'
|
|
8
8
|
|
|
9
|
+
/** Fields written by checks (not by the edit form). */
|
|
10
|
+
const ALERT_RUNTIME = ['state', 'lastCheckedAt', 'lastValue', 'lastResultId', 'lastFiredAt', 'lastError']
|
|
11
|
+
|
|
9
12
|
export const methods = {
|
|
10
13
|
// ---------------------------------------------------------------- alerts
|
|
11
14
|
|
|
@@ -53,6 +56,10 @@ export const methods = {
|
|
|
53
56
|
|
|
54
57
|
async saveAlert(input) {
|
|
55
58
|
const a = await this.normalizeAlert(input)
|
|
59
|
+
// A check may have finished between normalize and now: keep its runtime fields
|
|
60
|
+
// (last check / fire time, state) from the stored object, never the stale copy.
|
|
61
|
+
const current = (this.state.alerts ?? []).find(x => x.id === a.id)
|
|
62
|
+
if (current) for (const k of ALERT_RUNTIME) { if (current[k] === undefined) delete a[k]; else a[k] = current[k] }
|
|
56
63
|
this.state.alerts = upsert(this.state.alerts ?? [], a)
|
|
57
64
|
await this.save()
|
|
58
65
|
this.scheduleAlerts()
|
|
@@ -73,20 +80,18 @@ export const methods = {
|
|
|
73
80
|
|
|
74
81
|
/** Evaluate an unsaved alert definition (form "dry run"): nothing is stored or pushed. */
|
|
75
82
|
async previewAlert(input) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.state.alerts = [...(saved ?? []), draft]
|
|
81
|
-
return await this.checkAlertInner(tmpId, { dryRun: true })
|
|
82
|
-
} finally {
|
|
83
|
-
this.state.alerts = saved
|
|
84
|
-
}
|
|
83
|
+
// The draft is evaluated on its own: state.alerts is never touched, so concurrent
|
|
84
|
+
// saves are kept and the draft is never persisted.
|
|
85
|
+
const draft = await this.normalizeAlert({ ...input, id: undefined }, `preview-${newId('al')}`)
|
|
86
|
+
return this.checkAlertInner(draft.id, { dryRun: true, draft })
|
|
85
87
|
},
|
|
86
88
|
|
|
87
|
-
async checkAlertInner(id, { dryRun = false, force = false } = {}) {
|
|
88
|
-
const
|
|
89
|
-
if (!
|
|
89
|
+
async checkAlertInner(id, { dryRun = false, force = false, draft } = {}) {
|
|
90
|
+
const found = draft ?? (this.state.alerts ?? []).find(x => x.id === id)
|
|
91
|
+
if (!found) throw new UserError('nl2sql/invalid', '告警不存在')
|
|
92
|
+
// Evaluate a snapshot; runtime fields are applied below to the object stored
|
|
93
|
+
// *after* the query (saveAlert may have replaced it meanwhile).
|
|
94
|
+
let a = draft ? structuredClone(draft) : { ...found }
|
|
90
95
|
const ds = this.requireDatasource(a.datasourceId)
|
|
91
96
|
let result
|
|
92
97
|
if (a.source === 'metric') {
|
|
@@ -100,6 +105,10 @@ export const methods = {
|
|
|
100
105
|
const series = vi === undefined ? [] : result.rows.map(r => ({ t: r[0], v: r[vi] === null ? null : Number(r[vi]) }))
|
|
101
106
|
if (a.condition.type !== 'missing' && result.columns.length === 1 && vi === 0) series.splice(0, series.length, ...result.rows.map((r, i) => ({ t: i, v: Number(r[0]) })))
|
|
102
107
|
const ev = evaluateAlert(a.condition, series, { now: Date.now() })
|
|
108
|
+
if (!draft) {
|
|
109
|
+
const current = (this.state.alerts ?? []).find(x => x.id === id)
|
|
110
|
+
if (current) a = current
|
|
111
|
+
}
|
|
103
112
|
a.lastCheckedAt = Date.now()
|
|
104
113
|
a.lastValue = ev.value ?? null
|
|
105
114
|
a.lastResultId = result.resultId
|
|
@@ -163,12 +172,20 @@ export const methods = {
|
|
|
163
172
|
const last = a.lastCheckedAt ?? 0
|
|
164
173
|
if (a.every === '15m') return last + 15 * 60000
|
|
165
174
|
if (a.every === 'hour') return last + 60 * 60000
|
|
166
|
-
// daily at HH:MM local time: the most recent slot not yet covered by lastCheckedAt.
|
|
175
|
+
// daily at HH:MM local wall time: the most recent slot not yet covered by lastCheckedAt.
|
|
176
|
+
// DST (same rule as the Harness schedule service): a slot that does not exist
|
|
177
|
+
// that day (spring forward) is skipped; a repeated one (fall back) is the earlier instant.
|
|
167
178
|
const [hh, mm] = String(a.at ?? '09:00').split(':').map(Number)
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
179
|
+
const today = new Date(now)
|
|
180
|
+
const slotOn = (offset) => {
|
|
181
|
+
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + offset, hh, mm, 0, 0)
|
|
182
|
+
return d.getHours() === hh && d.getMinutes() === mm ? d.getTime() : undefined
|
|
183
|
+
}
|
|
184
|
+
let prev
|
|
185
|
+
for (let k = 0; k >= -3 && prev === undefined; k--) { const s = slotOn(k); if (s !== undefined && s <= now) prev = s }
|
|
186
|
+
if (prev !== undefined && last < prev) return prev
|
|
187
|
+
for (let k = 0; k <= 3; k++) { const s = slotOn(k); if (s !== undefined && s > now && s > last) return s }
|
|
188
|
+
return now + 86400000
|
|
172
189
|
},
|
|
173
190
|
|
|
174
191
|
async withJobLock(key, fn) {
|
|
@@ -192,12 +209,15 @@ export const methods = {
|
|
|
192
209
|
this.checkAlert(a.id),
|
|
193
210
|
new Promise((_, rej) => { const t = setTimeout(() => rej(new Error(`检查超过 ${Math.round(timeoutMs / 1000)} 秒`)), timeoutMs); t.unref?.() }),
|
|
194
211
|
])
|
|
195
|
-
|
|
212
|
+
// The stored object may have been replaced by an edit meanwhile.
|
|
213
|
+
const cur = (this.state.alerts ?? []).find(x => x.id === a.id)
|
|
214
|
+
if (cur) cur.lastError = undefined
|
|
196
215
|
ran.push({ id: a.id, ok: true })
|
|
197
216
|
} catch (error) {
|
|
198
217
|
if (error.code === 'nl2sql/busy') return
|
|
199
|
-
a.
|
|
200
|
-
|
|
218
|
+
const cur = (this.state.alerts ?? []).find(x => x.id === a.id) ?? a
|
|
219
|
+
cur.lastError = error.message
|
|
220
|
+
cur.lastCheckedAt = Date.now()
|
|
201
221
|
ran.push({ id: a.id, ok: false, error: error.message })
|
|
202
222
|
}
|
|
203
223
|
}))
|
package/service/bots.js
CHANGED
|
@@ -35,6 +35,29 @@ const toast = (type, content) => ({ toast: { type, content } })
|
|
|
35
35
|
const label = (platform) => PLATFORM_LABEL[platform] ?? platform ?? 'IM'
|
|
36
36
|
const SOURCE = (platform) => `${platform ?? 'im'}bot`
|
|
37
37
|
|
|
38
|
+
const BARE_COMMANDS = {
|
|
39
|
+
帮助: 'help', help: 'help', start: 'help',
|
|
40
|
+
数据源: 'sources', sources: 'sources', datasources: 'sources',
|
|
41
|
+
自动: 'auto', auto: 'auto',
|
|
42
|
+
新对话: 'reset', new: 'reset', reset: 'reset',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A bot command, or null for a question. Argument-less commands must be the whole
|
|
47
|
+
* message (optionally `/`-prefixed, trailing punctuation ignored), so questions like
|
|
48
|
+
* 「自动续费用户有多少」 reach the agent. `切换` / `切换数据源` / `switch` / `use` take an
|
|
49
|
+
* argument after whitespace; alone they ask for usage.
|
|
50
|
+
* → { verb: 'help'|'sources'|'auto'|'reset'|'switch', arg? }
|
|
51
|
+
*/
|
|
52
|
+
export function parseBotCommand(text) {
|
|
53
|
+
const s = String(text ?? '').trim().replace(/^\//, '').replace(/[\s??。.!!~~]+$/u, '')
|
|
54
|
+
const bare = BARE_COMMANDS[s.toLowerCase()]
|
|
55
|
+
if (bare) return { verb: bare }
|
|
56
|
+
const m = /^(切换数据源|切换|switch|use)(?:\s+([\s\S]+))?$/i.exec(s)
|
|
57
|
+
if (m) return { verb: 'switch', arg: (m[2] ?? '').trim() }
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
function hasAggregate(r, dialect) {
|
|
39
62
|
const parsed = parseSelect(r.baseSql ?? r.sql, { dialect })
|
|
40
63
|
return Boolean(parsed?.select?.some(i => i.isAggregate))
|
|
@@ -286,8 +309,8 @@ export const methods = {
|
|
|
286
309
|
if (m.chatType === 'group' && m.chatId) this.noteSeenIdentity({ platform, kind: 'chat', id: String(m.chatId), name: chatName, chatName, channelId: m.channelId, refused: Boolean(resolved.refused) })
|
|
287
310
|
if (resolved.refused) { await this.save(); await reply(resolved.refused); return }
|
|
288
311
|
const actor = resolved.actor
|
|
289
|
-
// Commands: 帮助 / 数据源 / 切换 <名称> / 新对话
|
|
290
|
-
const cmd = text
|
|
312
|
+
// Commands: 帮助 / 数据源 / 切换 <名称> / 自动 / 新对话 (and English aliases).
|
|
313
|
+
const cmd = parseBotCommand(text)
|
|
291
314
|
// Members with different scopes must not share a conversation: per-user sessions
|
|
292
315
|
// whenever the sender's effective scope or rules differ from the chat's.
|
|
293
316
|
const scopeSig = JSON.stringify([actor.datasourceIds, actor.role, actor.rowRules ?? []])
|
|
@@ -301,14 +324,15 @@ export const methods = {
|
|
|
301
324
|
const allowedDs = this.state.datasources.filter(d => !Array.isArray(actor.datasourceIds) || actor.datasourceIds.includes(d.id))
|
|
302
325
|
if (conv.datasourceId && !allowedDs.some(d => d.id === conv.datasourceId)) conv.datasourceId = allowedDs[0]?.id ?? null
|
|
303
326
|
if (cmd) {
|
|
304
|
-
const verb = cmd
|
|
305
|
-
if (verb === '
|
|
306
|
-
if (verb === '
|
|
307
|
-
if (verb === '
|
|
308
|
-
if (verb === '
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
327
|
+
const { verb } = cmd
|
|
328
|
+
if (verb === 'help') { await reply(`直接${m.chatType === 'group' ? ' @我 ' : ''}提问即可,例如「上周每天的订单量」。\n命令(单独发送):\n· 数据源 / sources — 查看可用数据源\n· 切换 <名称> / switch <name> — 固定使用某个数据源\n· 自动 / auto — 按问题自动选择数据源\n· 新对话 / new — 开始新对话\n· 帮助 / help — 显示本说明\n当前数据源:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定)' : allowedDs.length > 1 ? '(按问题自动选择)' : ''}${actor.role === 'viewer' ? '\n你是只读成员:可以查询指标库中的指标、已验证的常见问题和看板。' : ''}`); return }
|
|
329
|
+
if (verb === 'sources') { await reply(`可用数据源:\n${allowedDs.map(d => `· ${d.name}${d.description ? ` — ${d.description}` : ''}`).join('\n') || '无'}\n当前:${this.findDatasource(conv.datasourceId)?.name ?? '未选择'}${conv.datasourcePinned ? '(已指定,发送「自动」恢复按问题自动选择)' : allowedDs.length > 1 ? '(按问题自动选择;发送「切换 <名称>」固定)' : ''}`); return }
|
|
330
|
+
if (verb === 'auto') { delete conv.datasourcePinned; convs[key] = conv; await this.save(); await reply(`已改为按问题自动选择数据源(可用:${allowedDs.map(d => d.name).join('、')})。`); return }
|
|
331
|
+
if (verb === 'reset') { conv.sessionId = null; convs[key] = conv; await this.save(); await reply('已开始新对话。'); return }
|
|
332
|
+
if (!cmd.arg) { await reply(`用法:切换 <数据源名称>(或 switch <name>)。可用:${allowedDs.map(d => d.name).join('、') || '无'}`); return }
|
|
333
|
+
const want = cmd.arg.toLowerCase()
|
|
334
|
+
const target = allowedDs.find(d => d.name.toLowerCase() === want || d.id === cmd.arg) ?? allowedDs.filter(d => d.name.toLowerCase().includes(want) || (d.database ?? '').toLowerCase() === want).find((_, __, arr) => arr.length === 1)
|
|
335
|
+
if (!target) { await reply(`找不到可用的数据源「${cmd.arg}」。可用:${allowedDs.map(d => d.name).join('、')}`); return }
|
|
312
336
|
conv.datasourceId = target.id
|
|
313
337
|
conv.datasourcePinned = true
|
|
314
338
|
if (conv.sessionId) { this.state.sessions[conv.sessionId] = target.id }
|
|
@@ -417,7 +441,8 @@ export const methods = {
|
|
|
417
441
|
for (const d of list) {
|
|
418
442
|
let tables = ''
|
|
419
443
|
try {
|
|
420
|
-
|
|
444
|
+
// Policy-filtered, like nl2sql_schema: hidden tables never reach the bot prompt.
|
|
445
|
+
const schema = await this.cachedVisibleSchema(d)
|
|
421
446
|
if (schema?.tables?.length) {
|
|
422
447
|
const rank = { fact: 0, dimension: 1, view: 2, other: 3, log: 4, config: 5 }
|
|
423
448
|
const top = [...schema.tables].sort((a, b) => (rank[a.role] ?? 3) - (rank[b.role] ?? 3) || (b.rows ?? 0) - (a.rows ?? 0)).slice(0, 12)
|
|
@@ -475,7 +500,8 @@ export const methods = {
|
|
|
475
500
|
}
|
|
476
501
|
const waited = await controller.wait({ sessionId: sid, timeoutMs, maxMessages: 1 }, new AbortController().signal)
|
|
477
502
|
const reason = waited?.reason ?? 'idle'
|
|
478
|
-
|
|
503
|
+
// cancel() is async on DSH controllers: a rejection must not become unhandled.
|
|
504
|
+
if (reason !== 'idle') void Promise.resolve().then(() => controller.cancel({ sessionId: sid })).catch(() => {})
|
|
479
505
|
const answer = await this.collectTurnByRequest(sid, requestId)
|
|
480
506
|
return { sessionId: sid, reason, ...answer }
|
|
481
507
|
},
|
package/service/channels.js
CHANGED
|
@@ -19,6 +19,15 @@ import { UserError, newId, pick, requireText, upsert } from './shared.js'
|
|
|
19
19
|
const LARK_LEGACY = ['domain', 'appId', 'receiveIdType', 'receiveId']
|
|
20
20
|
const BOT_DEFAULTS = { enabled: false, allowP2p: true, sessionScope: 'chat' }
|
|
21
21
|
|
|
22
|
+
/** Truncate to `max` UTF-16 units with an ellipsis, without splitting a surrogate pair. */
|
|
23
|
+
export function clip(s, max) {
|
|
24
|
+
const str = String(s ?? '')
|
|
25
|
+
if (str.length <= max) return str
|
|
26
|
+
let cut = max - 1
|
|
27
|
+
if (/[\uD800-\uDBFF]/.test(str[cut - 1] ?? '')) cut--
|
|
28
|
+
return `${str.slice(0, cut)}…`
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/** ChannelError (or legacy ImError) → UserError with the same code and message. */
|
|
23
32
|
export function channelUserError(error, code) {
|
|
24
33
|
if (!(error instanceof ChannelError || (error?.code && /^(im|channel)\//.test(error.code)))) return error
|
|
@@ -172,8 +181,10 @@ export const methods = {
|
|
|
172
181
|
c.platform = adapter.id
|
|
173
182
|
c.description = String(input.description ?? c.description ?? '').slice(0, 300)
|
|
174
183
|
const { config: configIn, secrets: changed } = splitPayload(input, { ...kind, platform: adapter.id })
|
|
175
|
-
const stored = existing && !typeChanged ? await this.channelSecrets(existing) : {}
|
|
176
184
|
const keys = secretKeys(kind)
|
|
185
|
+
// A type change keeps stored secrets whose key the new kind also has (e.g. webhookUrl, token).
|
|
186
|
+
const stored = existing ? await this.channelSecrets(existing) : {}
|
|
187
|
+
if (typeChanged) for (const k of Object.keys(stored)) if (!keys.includes(k)) delete stored[k]
|
|
177
188
|
const merged = {}
|
|
178
189
|
for (const k of keys) {
|
|
179
190
|
if (Object.hasOwn(changed, k)) { if (changed[k] !== null) merged[k] = changed[k] } else if (stored[k] !== undefined) merged[k] = stored[k]
|
|
@@ -224,9 +235,19 @@ export const methods = {
|
|
|
224
235
|
await this.stopBot(id)
|
|
225
236
|
this.state.channels = this.state.channels.filter(x => x.id !== id)
|
|
226
237
|
if (c.isDefault && this.state.channels[0]) this.state.channels[0].isDefault = true
|
|
227
|
-
|
|
238
|
+
// Drop every reference to the channel: alerts, schema notifications, bot conversations, reports.
|
|
239
|
+
for (const a of this.state.alerts ?? []) if (a.channelId === id) a.channelId = null
|
|
240
|
+
if (this.state.settings?.schemaNotifyChannel === id) this.state.settings.schemaNotifyChannel = null
|
|
241
|
+
for (const [k, conv] of Object.entries(this.state.botConversations ?? {})) if (conv?.channelId === id) delete this.state.botConversations[k]
|
|
242
|
+
const reports = (this.state.reports ?? []).filter(r => r.channelId === id)
|
|
243
|
+
for (const r of reports) delete r.channelId
|
|
228
244
|
try { await this.ctx.get('credentials')?.deleteRecord(this.channelSecretKey(id)) } catch {}
|
|
229
245
|
await this.save()
|
|
246
|
+
// Scheduled report prompts name the channel: rewrite them through the normal report save.
|
|
247
|
+
for (const r of reports) {
|
|
248
|
+
if (!r.scheduleId || !this.ctx.get('schedule')) continue
|
|
249
|
+
try { await this.saveReport({ ...r, channelId: undefined }) } catch (error) { console.warn('nl2sql: cannot update report %s after channel delete: %s', r.id, error.message) }
|
|
250
|
+
}
|
|
230
251
|
return { deleted: id }
|
|
231
252
|
},
|
|
232
253
|
|
|
@@ -311,10 +332,10 @@ export const methods = {
|
|
|
311
332
|
const modes = chartModesOf(found.kind)
|
|
312
333
|
const body = text ? String(text).slice(0, 20000) : ''
|
|
313
334
|
const message = {
|
|
314
|
-
title:
|
|
335
|
+
title: clip(String(title || resolved[0]?.title || '').trim() || '数据问答', 100),
|
|
315
336
|
text: body,
|
|
316
337
|
chartMode: modes.includes(chartMode) ? chartMode : modes.includes(c.chartMode) ? c.chartMode : modes[0],
|
|
317
|
-
results: resolved.map(r => pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])])),
|
|
338
|
+
results: resolved.map(r => ({ ...pick(r, ['title', 'columns', 'rows', 'rowCount', 'truncated', 'chart', 'sql', 'filters', 'error', ...(found.kind.capabilities?.buttons ? ['actions'] : [])]), ...(r.title ? { title: clip(String(r.title), 100) } : {}) })),
|
|
318
339
|
includeSql: Boolean(includeSql),
|
|
319
340
|
mentionAll: mentionAll ?? /<at\s+id\s*=\s*["']?all["']?\s*>/i.test(body),
|
|
320
341
|
...(template ? { template } : {}),
|
|
@@ -328,7 +349,7 @@ export const methods = {
|
|
|
328
349
|
if (caps.files && wantAttach) {
|
|
329
350
|
const idx = message.results.map((r, i) => (needsAttachment(r, shown) ? i : -1)).filter(i => i >= 0)
|
|
330
351
|
if (idx.length) {
|
|
331
|
-
const sheets = idx.map(i => ({ name:
|
|
352
|
+
const sheets = idx.map(i => ({ name: message.results[i].title || `结果${i + 1}`, columns: message.results[i].columns, rows: message.results[i].rows }))
|
|
332
353
|
if (includeSql) sheets.push({ name: 'SQL', columns: [{ name: '结果', kind: 'text' }, { name: 'SQL', kind: 'text' }], rows: idx.map(i => [message.results[i].title ?? '', message.results[i].sql ?? '']) })
|
|
333
354
|
const buffer = writeXlsx(sheets, { creator: 'DeepSeek Harness 数据问答' })
|
|
334
355
|
const safe = String(message.title).replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 60) || '数据'
|
package/service/dashboards.js
CHANGED
|
@@ -296,8 +296,11 @@ export const methods = {
|
|
|
296
296
|
// --------------------------------------------------------- auto overview
|
|
297
297
|
|
|
298
298
|
/** Build overview widgets for one table from its column roles; runs each query and returns results. */
|
|
299
|
-
async tableOverview({ datasourceId, table, pin = false }) {
|
|
299
|
+
async tableOverview({ datasourceId, table, pin = false, actor, sessionId, source = 'ui' }) {
|
|
300
300
|
const ds = this.requireDatasource(datasourceId)
|
|
301
|
+
// Agent / bot callers run as their actor: scope, row-level rules, viewer limits and audit attribution apply.
|
|
302
|
+
actor ??= sessionId ? this.actorFor(sessionId) : undefined
|
|
303
|
+
if (actor) this.assertAccess(actor, ds)
|
|
301
304
|
const schema = await this.visibleSchema(ds)
|
|
302
305
|
const t = schema.tables.find(x => x.name === table)
|
|
303
306
|
if (!t) throw new UserError('nl2sql/unknown-table', `表不存在或无权访问:${table}`)
|
|
@@ -305,7 +308,7 @@ export const methods = {
|
|
|
305
308
|
const results = []
|
|
306
309
|
for (const plan of plans) {
|
|
307
310
|
try {
|
|
308
|
-
const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, title: plan.title, chart: plan.chart, source
|
|
311
|
+
const r = await this.runQuery({ sql: plan.sql, datasourceId: ds.id, sessionId, actor, title: plan.title, chart: plan.chart, source, confirmScan: true })
|
|
309
312
|
results.push(r)
|
|
310
313
|
if (pin) await this.pinWidget({ title: plan.title, sql: plan.sql, datasourceId: ds.id, chart: r.chart, width: plan.width })
|
|
311
314
|
} catch (error) {
|
package/service/datasources.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*/
|
|
5
5
|
import { DIALECTS, createDriver, describeError } from '../drivers.js'
|
|
6
|
-
import { normalizePolicy, policyActive } from '../policy.js'
|
|
6
|
+
import { filterSchema, normalizePolicy, policyActive } from '../policy.js'
|
|
7
7
|
import { sanitizeTableName } from '../sqlite-driver.js'
|
|
8
8
|
import { join } from 'node:path'
|
|
9
9
|
import { CREDENTIAL_SCOPE, DEFAULT_SETTINGS, RETENTION_MAX, RETENTION_MIN, SUPPORTED_TYPES, UserError, clampInt, newId, oneOf, pick, requireText } from './shared.js'
|
|
@@ -308,14 +308,27 @@ export const methods = {
|
|
|
308
308
|
return current
|
|
309
309
|
},
|
|
310
310
|
|
|
311
|
-
|
|
311
|
+
/**
|
|
312
|
+
* The already-analyzed schema (memory or file, no introspection) restricted by the
|
|
313
|
+
* access policy — the same view nl2sql_schema gives the model. Undefined when not analyzed.
|
|
314
|
+
*/
|
|
315
|
+
async cachedVisibleSchema(ds) {
|
|
316
|
+
const schema = this.schemas.get(ds.id) ?? await this.readSchemaFile(ds.id)
|
|
317
|
+
return schema ? filterSchema(schema, this.policyFor(ds)) : undefined
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Bind a session to a data source. The page RPC also makes it the page's current data source
|
|
322
|
+
* (default); agent tools (including bot sessions) pass setCurrent: false so they never move it.
|
|
323
|
+
*/
|
|
324
|
+
async bindSession(sessionId, datasourceId, { setCurrent = true } = {}) {
|
|
312
325
|
if (!sessionId) throw new UserError('nl2sql/invalid', '缺少会话')
|
|
313
326
|
if (datasourceId === null) {
|
|
314
327
|
delete this.state.sessions[sessionId]
|
|
315
328
|
} else {
|
|
316
329
|
const ds = this.requireDatasource(datasourceId)
|
|
317
330
|
this.state.sessions[sessionId] = ds.id
|
|
318
|
-
this.state.current = ds.id
|
|
331
|
+
if (setCurrent) this.state.current = ds.id
|
|
319
332
|
}
|
|
320
333
|
await this.save()
|
|
321
334
|
return this.sessionInfo(sessionId)
|
package/service/query.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
4
|
*/
|
|
5
5
|
import { aliasExpr, buildAttributionSql, buildBreakdownSql, buildDetailSql, buildMetricSql, computeAttribution, isAdditive, parseSelect, quoteName, rankDimensions } from '../analysis.js'
|
|
6
|
-
import { applyFilters, normalizeFilters } from '../filters.js'
|
|
6
|
+
import { FilterInjectionError, applyFilters, normalizeFilters } from '../filters.js'
|
|
7
7
|
import { checkAccess, filterSchema, policyActive, referencedTables, resultColumnPolicy } from '../policy.js'
|
|
8
8
|
import { admitSql, prepareSql, stripSql } from '../sql-guard.js'
|
|
9
9
|
import { cacheKey } from '../store.js'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
10
11
|
import { writeXlsx } from '../xlsx.js'
|
|
11
12
|
import { RESULT_CACHE_LIMIT, SENSITIVE_NAME, UserError, columnKind, fillChartHint, fmtNum, maskValue, newId, normalizeChart, suggestChart, summarize } from './shared.js'
|
|
12
13
|
|
|
@@ -36,12 +37,19 @@ export const methods = {
|
|
|
36
37
|
}
|
|
37
38
|
let appliedFilters = []
|
|
38
39
|
if (filters?.length) {
|
|
39
|
-
|
|
40
|
+
let f
|
|
41
|
+
try { f = applyFilters(admitted.sql, normalizeFilters(filters), { dialect, schema: filterSchema(fullSchema, policy) }) } catch (error) {
|
|
42
|
+
if (!(error instanceof FilterInjectionError)) throw error
|
|
43
|
+
this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'blocked', error: 'filter injection refused', actor })
|
|
44
|
+
await this.save()
|
|
45
|
+
throw new UserError('nl2sql/invalid', '无法安全地为这条查询加上筛选条件,已拒绝。请改写查询(检查括号与注释)。')
|
|
46
|
+
}
|
|
40
47
|
appliedFilters = f.applied
|
|
41
48
|
if (f.applied.length) admitted = admitSql(f.sql, { dialect })
|
|
42
49
|
}
|
|
43
50
|
// Row-level security: every rule must be injected into the outer query; otherwise refuse.
|
|
44
51
|
const rowRules = this.rowRulesFor(actor, ds)
|
|
52
|
+
const rowRulesSig = rowRules.length ? rowRulesSignature(rowRules) : undefined
|
|
45
53
|
if (rowRules.length) {
|
|
46
54
|
const schemaForRules = fullSchema ?? await this.analyzeSchema(ds.id)
|
|
47
55
|
const refs = referencedTables(admitted.sql, schemaForRules.tables, { dialect })
|
|
@@ -55,7 +63,13 @@ export const methods = {
|
|
|
55
63
|
await this.save()
|
|
56
64
|
throw new UserError('nl2sql/row-policy', `当前成员受行级权限限制(${needed.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}),查询不能包含子查询、CTE 或 UNION,请改写为单层查询。`)
|
|
57
65
|
}
|
|
58
|
-
|
|
66
|
+
let f
|
|
67
|
+
try { f = applyFilters(admitted.sql, needed.map(r => ({ kind: 'in', column: r.column, values: r.values })), { dialect, schema: schemaForRules }) } catch (error) {
|
|
68
|
+
if (!(error instanceof FilterInjectionError)) throw error
|
|
69
|
+
this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'blocked', error: 'row-level policy: injection refused', actor })
|
|
70
|
+
await this.save()
|
|
71
|
+
throw new UserError('nl2sql/row-policy', `当前成员受行级权限限制(${needed.map(r => `${r.column} ∈ {${r.values.join(', ')}}`).join(';')}),这条查询无法安全地加上该过滤,已拒绝。`)
|
|
72
|
+
}
|
|
59
73
|
const missing = needed.filter(r => !f.applied.some(a => a.column.toLowerCase() === r.column.toLowerCase()))
|
|
60
74
|
// Every referenced table that has the column must be covered.
|
|
61
75
|
const tablesWithCol = (col) => refs.filter(t => schemaForRules.tables.find(x => x.name === t)?.columns.some(c => c.name.toLowerCase() === col.toLowerCase()))
|
|
@@ -71,10 +85,10 @@ export const methods = {
|
|
|
71
85
|
}
|
|
72
86
|
// Query cache: dashboards, reruns and reports reuse a recent identical result.
|
|
73
87
|
const ttlMs = (settings.cacheTtlSec ?? 0) * 1000
|
|
74
|
-
const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules]) : null
|
|
88
|
+
const key = ttlMs > 0 && useCache && this.store ? cacheKey([ds.id, admitted.sql, settings.maxRows, settings.maskSensitive, ds.policy ?? null, ds.updatedAt ?? 0, rowRules, rowRulesSig ?? null]) : null
|
|
75
89
|
if (key) {
|
|
76
90
|
const hit = this.store.cached(key, ttlMs)
|
|
77
|
-
if (hit) {
|
|
91
|
+
if (hit && (hit.rowRulesSig ?? undefined) === rowRulesSig) {
|
|
78
92
|
const result = { ...hit, resultId: newId('r'), title: title ? String(title).slice(0, 120) : hit.title, chart: normalizeChart(chart, hit.columns) ?? hit.chart, cached: true, cachedAt: hit.createdAt, createdAt: Date.now(), ...(appliedFilters.length ? { filters: appliedFilters, baseSql: sql } : {}) }
|
|
79
93
|
this.remember(result)
|
|
80
94
|
this.audit({ ds, sessionId, source, sql: admitted.sql, title, status: 'ok', rows: result.rowCount, elapsedMs: 0, resultId: result.resultId, cached: true, actor })
|
|
@@ -134,6 +148,7 @@ export const methods = {
|
|
|
134
148
|
executedSql: prepared,
|
|
135
149
|
...(appliedFilters.length ? { filters: appliedFilters, baseSql: sql } : {}),
|
|
136
150
|
...(colPolicy.drop.size ? { hiddenColumns: colPolicy.drop.size } : {}),
|
|
151
|
+
...(rowRulesSig ? { rowRulesSig } : {}),
|
|
137
152
|
title: title ? String(title).slice(0, 120) : '',
|
|
138
153
|
columns,
|
|
139
154
|
rows: cells,
|
|
@@ -211,11 +226,17 @@ export const methods = {
|
|
|
211
226
|
return this.lookupResult(resultId) ?? null
|
|
212
227
|
},
|
|
213
228
|
|
|
214
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* A scoped actor may only use results of data sources in scope, and — when it
|
|
231
|
+
* has row rules — only results filtered by exactly its own rules (signature
|
|
232
|
+
* match; results without a signature, e.g. from older versions, are refused).
|
|
233
|
+
*/
|
|
215
234
|
assertResultAccess(r, actor) {
|
|
216
235
|
if (!actor || actor.kind === 'owner') return
|
|
217
|
-
|
|
218
|
-
|
|
236
|
+
const ds = this.requireDatasource(r.datasourceId)
|
|
237
|
+
this.assertAccess(actor, ds)
|
|
238
|
+
const rules = this.rowRulesFor(actor, ds)
|
|
239
|
+
if (rules.length && (!r.filters?.some(f => f.rowPolicy) || r.rowRulesSig !== rowRulesSignature(rules))) throw new UserError('nl2sql/row-policy', '该结果未按你的行级权限过滤,不能使用,请重新查询')
|
|
219
240
|
},
|
|
220
241
|
|
|
221
242
|
cacheStats() {
|
|
@@ -409,3 +430,10 @@ export const methods = {
|
|
|
409
430
|
return { fileName: file, mediaType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', base64: buf.toString('base64'), bytes: buf.length, sheets: sheets.length }
|
|
410
431
|
},
|
|
411
432
|
}
|
|
433
|
+
|
|
434
|
+
/** Stable signature of a set of row rules (order of rules and of values does not matter). */
|
|
435
|
+
export function rowRulesSignature(rules) {
|
|
436
|
+
const norm = rules.map(r => ({ column: String(r.column).toLowerCase(), values: [...new Set((r.values ?? []).map(String))].sort() }))
|
|
437
|
+
.sort((a, b) => (a.column + '\0' + a.values.join('\0')).localeCompare(b.column + '\0' + b.values.join('\0')))
|
|
438
|
+
return createHash('sha256').update(JSON.stringify(norm)).digest('hex').slice(0, 32)
|
|
439
|
+
}
|
package/service/uploads.js
CHANGED
|
@@ -202,8 +202,11 @@ export const methods = {
|
|
|
202
202
|
fileName = fileName || staged.fileName
|
|
203
203
|
raw = staged.raw
|
|
204
204
|
}
|
|
205
|
+
fileName = String(fileName ?? '').trim().slice(0, 255)
|
|
206
|
+
if (!raw && !fileName) throw new UserError('nl2sql/invalid', '缺少文件名')
|
|
205
207
|
let ds = datasourceId ? this.requireDatasource(datasourceId) : undefined
|
|
206
|
-
|
|
208
|
+
// The description is left empty for the user to fill in (it is shown to the model and in bot catalogs).
|
|
209
|
+
if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || (fileName || 'uploaded-file').replace(/\.[^.]+$/, '').slice(0, 60) })
|
|
207
210
|
if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '只能导入到「本地文件」类型的数据源')
|
|
208
211
|
const ds2 = this.requireDatasource(ds.id)
|
|
209
212
|
if (!raw) {
|
package/sql-guard.js
CHANGED
|
@@ -34,7 +34,7 @@ const FORBIDDEN = [
|
|
|
34
34
|
* Per-dialect lexical rules. Getting these exactly right matters: if the guard
|
|
35
35
|
* thinks a quote is escaped when the server does not (or vice versa), text the
|
|
36
36
|
* server executes would be hidden inside a "string".
|
|
37
|
-
* mysql:
|
|
37
|
+
* mysql: `--` starts a comment only before a char <= 0x20 or end of input, `#` comments, backslash escapes in '…' and "…", `…` identifiers
|
|
38
38
|
* postgres: any `--` comments, no `#`, backslash escapes only inside E'…', $tag$…$tag$ dollar quoting, "…" identifiers
|
|
39
39
|
* sqlite: any `--` comments, no `#`, no backslash escapes, "…" / `…` / […] identifiers
|
|
40
40
|
* elasticsearch: any `--` comments, no `#`, no backslash escapes, "…" identifiers
|
|
@@ -52,6 +52,35 @@ export function dialectRules(dialect = 'mysql') {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* MySQL starts a `--` comment only when the next character is a control
|
|
57
|
+
* character or space (code <= 0x20) or the input ends. Unicode spaces such as
|
|
58
|
+
* U+00A0 do NOT start a comment (JS `\s` would wrongly say they do).
|
|
59
|
+
*/
|
|
60
|
+
export function dashDashCommentFollower(ch) {
|
|
61
|
+
return ch === undefined || ch.charCodeAt(0) <= 0x20
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Characters refused outside string literals / quoted identifiers / comments:
|
|
66
|
+
* C0 controls other than \t \n \r, DEL, and any non-ASCII whitespace or
|
|
67
|
+
* format character. Servers disagree with each other (and with JS) about these,
|
|
68
|
+
* so they are a lexer-confusion hazard and never needed in real SQL.
|
|
69
|
+
*/
|
|
70
|
+
const SUSPICIOUS_CODE_CHAR = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F\u00A0\u1680\u180E\u2000-\u200F\u2028-\u202F\u205F-\u2064\u3000\uFEFF]/
|
|
71
|
+
|
|
72
|
+
/** First suspicious character in the code (non-literal) parts of the SQL, or undefined. */
|
|
73
|
+
export function suspiciousCodeChar(sql, { dialect = 'mysql' } = {}) {
|
|
74
|
+
const { segments } = lexSql(sql, { dialect })
|
|
75
|
+
for (const s of segments) {
|
|
76
|
+
// Comments are checked too: a control character right after `--` is exactly the ambiguity being refused.
|
|
77
|
+
if (s.kind === 'string' || s.kind === 'ident') continue
|
|
78
|
+
const m = SUSPICIOUS_CODE_CHAR.exec(s.text)
|
|
79
|
+
if (m) return m[0]
|
|
80
|
+
}
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
/**
|
|
56
85
|
* Tokenize SQL into code / string / identifier / comment segments using the
|
|
57
86
|
* dialect's lexical rules. Unterminated strings, identifiers and comments are
|
|
@@ -72,7 +101,7 @@ export function lexSql(sql, { dialect = 'mysql' } = {}) {
|
|
|
72
101
|
const c = sql[i]
|
|
73
102
|
const n = sql[i + 1]
|
|
74
103
|
// -- comments
|
|
75
|
-
if (c === '-' && n === '-' && (!r.dashDashNeedsSpace ||
|
|
104
|
+
if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || dashDashCommentFollower(sql[i + 2]))) {
|
|
76
105
|
const start = i
|
|
77
106
|
while (i < sql.length && sql[i] !== '\n') i++
|
|
78
107
|
push('comment', start, i)
|
|
@@ -167,6 +196,8 @@ export function admitSql(input, { dialect = 'mysql' } = {}) {
|
|
|
167
196
|
const { code, executableComment, unterminated } = stripSql(sql, { dialect })
|
|
168
197
|
if (executableComment) throw new Error('MySQL executable comments (/*! ... */) are not allowed')
|
|
169
198
|
if (unterminated) throw new Error('unterminated string, identifier or comment')
|
|
199
|
+
const bad = suspiciousCodeChar(sql, { dialect })
|
|
200
|
+
if (bad) throw new Error(`control or non-ASCII whitespace character U+${bad.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')} is only allowed inside string literals or quoted identifiers`)
|
|
170
201
|
if (code.includes(';')) throw new Error('only one statement may be executed at a time')
|
|
171
202
|
const head = (code.trim().match(/^\(*\s*([A-Za-z]+)/)?.[1] ?? '').toUpperCase()
|
|
172
203
|
const allowed = ALLOWED_HEADS[dialect] ?? ALLOWED_HEADS.mysql
|
package/xlsx.js
CHANGED
|
@@ -208,6 +208,16 @@ const STYLES_XML = XML_HEAD +
|
|
|
208
208
|
|
|
209
209
|
const SHEET_NAME_BAD_RE = /[\[\]:*?/\\]/g;
|
|
210
210
|
|
|
211
|
+
/** First `max` UTF-16 units of `s` (Excel's sheet-name limit), never splitting a surrogate pair. */
|
|
212
|
+
function utf16Prefix(s, max) {
|
|
213
|
+
let out = '';
|
|
214
|
+
for (const ch of s) {
|
|
215
|
+
if (out.length + ch.length > max) break;
|
|
216
|
+
out += ch;
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
211
221
|
/** Make Excel-safe, unique (case-insensitive), non-empty sheet names. */
|
|
212
222
|
function sanitizeSheetNames(names) {
|
|
213
223
|
const used = new Set(['history']); // "History" is reserved by Excel
|
|
@@ -215,11 +225,11 @@ function sanitizeSheetNames(names) {
|
|
|
215
225
|
let base = stripIllegal(String(raw ?? '')).replace(SHEET_NAME_BAD_RE, '_').replace(/[\r\n\t]/g, ' ').trim();
|
|
216
226
|
base = base.replace(/^'+|'+$/g, '').trim();
|
|
217
227
|
if (!base) base = `Sheet${i + 1}`;
|
|
218
|
-
base =
|
|
228
|
+
base = utf16Prefix(base, 31);
|
|
219
229
|
let name = base;
|
|
220
230
|
for (let n = 2; used.has(name.toLowerCase()); n++) {
|
|
221
231
|
const suffix = ` (${n})`;
|
|
222
|
-
name =
|
|
232
|
+
name = utf16Prefix(base, 31 - suffix.length).trimEnd() + suffix;
|
|
223
233
|
}
|
|
224
234
|
used.add(name.toLowerCase());
|
|
225
235
|
return name;
|