@hedeqiang/dsh-nl2sql 0.0.0-stage → 0.10.0
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 +32 -0
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +88 -2
- package/analysis.js +888 -0
- package/cordis.patch.yml +68 -0
- package/drivers.js +694 -0
- package/filters.js +237 -0
- package/icon.svg +13 -0
- package/im.js +511 -0
- package/index.js +650 -0
- package/lark-bot.js +606 -0
- package/lib/client.echarts.js +57 -0
- package/lib/client.js +4845 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +90 -4
- package/policy.js +362 -0
- package/service/access.js +145 -0
- package/service/admin.js +539 -0
- package/service/alerts.js +233 -0
- package/service/dashboards.js +359 -0
- package/service/datasources.js +392 -0
- package/service/evaluation.js +121 -0
- package/service/lark-actions.js +223 -0
- package/service/lark.js +250 -0
- package/service/push.js +314 -0
- package/service/query.js +411 -0
- package/service/schema.js +507 -0
- package/service/shared.js +424 -0
- package/service/suggestions.js +772 -0
- package/service/uploads.js +258 -0
- package/sql-guard.js +222 -0
- package/sqlite-driver.js +677 -0
- package/store.js +225 -0
- package/xlsx.js +1001 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nl2SqlService methods: uploads.
|
|
3
|
+
* Mixed into the service prototype by index.js; `this` is the service.
|
|
4
|
+
*
|
|
5
|
+
* Files can be sent once (base64 in parseUpload / importUpload), or staged on
|
|
6
|
+
* the server: stageUpload (one request) or stageUploadChunk (several requests)
|
|
7
|
+
* keep the bytes under <dataDir>/uploads/<uploadId> (0600) for one hour, and
|
|
8
|
+
* importUpload({ uploadId }) imports them without the browser re-sending.
|
|
9
|
+
*/
|
|
10
|
+
import { appendFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { dropTable, importTable, listTables, sanitizeTableName } from '../sqlite-driver.js'
|
|
13
|
+
import { coerceValue, inferColumns, parseCsv, readXlsx } from '../xlsx.js'
|
|
14
|
+
import { UserError, newId } from './shared.js'
|
|
15
|
+
|
|
16
|
+
export const UPLOAD_MAX_BYTES = 200 * 1024 * 1024
|
|
17
|
+
export const UPLOAD_TTL_MS = 60 * 60 * 1000
|
|
18
|
+
const UPLOAD_ID = /^up-[0-9a-f]{10}$/
|
|
19
|
+
|
|
20
|
+
const isText = (fileName) => /\.(csv|tsv|txt)$/i.test(String(fileName ?? ''))
|
|
21
|
+
|
|
22
|
+
/** Raw sheets of a CSV / Excel file: [{ name, rows, encoding?, delimiter?, truncated }]. */
|
|
23
|
+
function readSheets(fileName, buf) {
|
|
24
|
+
try {
|
|
25
|
+
if (isText(fileName)) {
|
|
26
|
+
const p = parseCsv(buf)
|
|
27
|
+
return [{ name: String(fileName).replace(/\.[^.]+$/, ''), rows: p.rows, encoding: p.encoding, delimiter: p.delimiter, truncated: p.truncated }]
|
|
28
|
+
}
|
|
29
|
+
return readXlsx(buf)
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new UserError('nl2sql/invalid', error.message)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Preview shape returned by parseUpload / stageUpload. */
|
|
36
|
+
function previewSheets(raw, fileName, headerRow) {
|
|
37
|
+
return raw.filter(sh => sh.rows.some(r => r.length)).map(sh => {
|
|
38
|
+
const rows = sh.rows.filter(r => r.some(v => v !== null && v !== undefined && v !== ''))
|
|
39
|
+
const header = headerRow ? rows[0] ?? [] : (rows[0] ?? []).map((_, i) => `column_${i + 1}`)
|
|
40
|
+
const body = headerRow ? rows.slice(1) : rows
|
|
41
|
+
const width = Math.max(header.length, ...body.slice(0, 1000).map(r => r.length))
|
|
42
|
+
const cols = inferColumns(Array.from({ length: width }, (_, i) => header[i] ?? ''), body.map(r => r.map(v => v === null || v === undefined ? '' : String(v))))
|
|
43
|
+
return { sheet: sh.name, table: sanitizeTableName(sh.name || fileName || 'data'), columns: cols, rowCount: body.length, preview: body.slice(0, 20), encoding: sh.encoding, delimiter: sh.delimiter, truncated: Boolean(sh.truncated) }
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function decode(base64) {
|
|
48
|
+
return Buffer.from(String(base64 ?? ''), 'base64')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function checkSize(n) {
|
|
52
|
+
if (n > UPLOAD_MAX_BYTES) throw new UserError('nl2sql/invalid', '文件超过 200 MB')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const methods = {
|
|
56
|
+
// ----------------------------------------------------------- file uploads
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse an uploaded CSV / Excel file (base64) into a preview: sheets with
|
|
60
|
+
* inferred column types and the first rows, without importing.
|
|
61
|
+
*/
|
|
62
|
+
parseUpload({ fileName, base64, headerRow = true }) {
|
|
63
|
+
const buf = decode(base64)
|
|
64
|
+
if (!buf.length) throw new UserError('nl2sql/invalid', '文件为空')
|
|
65
|
+
checkSize(buf.length)
|
|
66
|
+
return previewSheets(readSheets(fileName, buf), fileName, headerRow)
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
uploadDir() {
|
|
70
|
+
return join(this.dir, 'uploads')
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
uploadPath(id) {
|
|
74
|
+
if (!UPLOAD_ID.test(String(id ?? ''))) throw new UserError('nl2sql/invalid', '上传编号无效')
|
|
75
|
+
return join(this.uploadDir(), id)
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
/** Delete staged files (complete or partial) older than the TTL. */
|
|
79
|
+
async cleanupUploads(now = Date.now()) {
|
|
80
|
+
let names = []
|
|
81
|
+
try { names = await readdir(this.uploadDir()) } catch { return 0 }
|
|
82
|
+
let removed = 0
|
|
83
|
+
for (const name of names) {
|
|
84
|
+
const id = name.replace(/\.json$/, '')
|
|
85
|
+
const file = join(this.uploadDir(), name)
|
|
86
|
+
try {
|
|
87
|
+
const st = await stat(file)
|
|
88
|
+
if (now - st.mtimeMs <= UPLOAD_TTL_MS) continue
|
|
89
|
+
await rm(file, { force: true })
|
|
90
|
+
this.uploadSessions?.delete(id)
|
|
91
|
+
if (this.stagedParse?.id === id) this.stagedParse = null
|
|
92
|
+
removed++
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
return removed
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async writeUploadMeta(id, meta) {
|
|
99
|
+
await writeFile(`${this.uploadPath(id)}.json`, JSON.stringify(meta), { mode: 0o600 })
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async readUploadMeta(id) {
|
|
103
|
+
try { return JSON.parse(await readFile(`${this.uploadPath(id)}.json`, 'utf8')) } catch { return undefined }
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/** Parse a fully staged file once, remember the parse for the following import, return the preview. */
|
|
107
|
+
async finishStage(id, fileName, buf, headerRow) {
|
|
108
|
+
const raw = readSheets(fileName, buf)
|
|
109
|
+
// Keep only the latest parse in memory: importUpload right after staging reuses it.
|
|
110
|
+
this.stagedParse = { id, fileName, raw }
|
|
111
|
+
await this.writeUploadMeta(id, { fileName, bytes: buf.length, complete: true, createdAt: Date.now() })
|
|
112
|
+
return { uploadId: id, fileName, bytes: buf.length, sheets: previewSheets(raw, fileName, headerRow) }
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
/** Store one whole file (base64) on the server, parse it once, return the preview. */
|
|
116
|
+
async stageUpload({ fileName, base64, headerRow = true }) {
|
|
117
|
+
await this.cleanupUploads()
|
|
118
|
+
const name = String(fileName ?? '').trim().slice(0, 255)
|
|
119
|
+
if (!name) throw new UserError('nl2sql/invalid', '缺少文件名')
|
|
120
|
+
const buf = decode(base64)
|
|
121
|
+
if (!buf.length) throw new UserError('nl2sql/invalid', '文件为空')
|
|
122
|
+
checkSize(buf.length)
|
|
123
|
+
await mkdir(this.uploadDir(), { recursive: true, mode: 0o700 })
|
|
124
|
+
const id = newId('up')
|
|
125
|
+
await writeFile(this.uploadPath(id), buf, { mode: 0o600 })
|
|
126
|
+
try {
|
|
127
|
+
return await this.finishStage(id, name, buf, headerRow)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
await this.discardUpload(id)
|
|
130
|
+
throw error
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Chunked staging: chunks arrive in order (index 0..total-1). The first
|
|
136
|
+
* chunk opens an upload (uploadId omitted); later ones must name it. The
|
|
137
|
+
* last chunk parses the file and returns the stageUpload result; earlier
|
|
138
|
+
* chunks return { uploadId, received }.
|
|
139
|
+
*/
|
|
140
|
+
async stageUploadChunk({ uploadId, fileName, index, total, base64, headerRow = true }) {
|
|
141
|
+
this.uploadSessions ??= new Map()
|
|
142
|
+
const idx = Number(index)
|
|
143
|
+
const tot = Number(total)
|
|
144
|
+
if (!Number.isInteger(tot) || tot < 1 || tot > 100000) throw new UserError('nl2sql/invalid', 'total 无效')
|
|
145
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= tot) throw new UserError('nl2sql/invalid', 'index 无效')
|
|
146
|
+
const chunk = decode(base64)
|
|
147
|
+
let up
|
|
148
|
+
if (idx === 0 && !uploadId) {
|
|
149
|
+
await this.cleanupUploads()
|
|
150
|
+
const name = String(fileName ?? '').trim().slice(0, 255)
|
|
151
|
+
if (!name) throw new UserError('nl2sql/invalid', '缺少文件名')
|
|
152
|
+
await mkdir(this.uploadDir(), { recursive: true, mode: 0o700 })
|
|
153
|
+
up = { id: newId('up'), fileName: name, total: tot, next: 0, bytes: 0, startedAt: Date.now() }
|
|
154
|
+
await writeFile(this.uploadPath(up.id), Buffer.alloc(0), { mode: 0o600 })
|
|
155
|
+
this.uploadSessions.set(up.id, up)
|
|
156
|
+
} else {
|
|
157
|
+
up = uploadId ? this.uploadSessions.get(String(uploadId)) : undefined
|
|
158
|
+
if (!up) throw new UserError('nl2sql/invalid', '上传不存在或已过期,请重新上传')
|
|
159
|
+
if (tot !== up.total) throw new UserError('nl2sql/invalid', 'total 与首个分片不一致')
|
|
160
|
+
if (idx !== up.next) throw new UserError('nl2sql/invalid', `分片顺序错误:期望第 ${up.next} 片,收到第 ${idx} 片`)
|
|
161
|
+
}
|
|
162
|
+
if (up.bytes + chunk.length > UPLOAD_MAX_BYTES) {
|
|
163
|
+
await this.discardUpload(up.id)
|
|
164
|
+
throw new UserError('nl2sql/invalid', '文件超过 200 MB')
|
|
165
|
+
}
|
|
166
|
+
await appendFile(this.uploadPath(up.id), chunk)
|
|
167
|
+
up.bytes += chunk.length
|
|
168
|
+
up.next = idx + 1
|
|
169
|
+
if (up.next < up.total) return { uploadId: up.id, received: up.next }
|
|
170
|
+
this.uploadSessions.delete(up.id)
|
|
171
|
+
if (!up.bytes) { await this.discardUpload(up.id); throw new UserError('nl2sql/invalid', '文件为空') }
|
|
172
|
+
try {
|
|
173
|
+
return await this.finishStage(up.id, up.fileName, await readFile(this.uploadPath(up.id)), headerRow)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
await this.discardUpload(up.id)
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
async discardUpload(id) {
|
|
181
|
+
this.uploadSessions?.delete(id)
|
|
182
|
+
if (this.stagedParse?.id === id) this.stagedParse = null
|
|
183
|
+
try { await rm(this.uploadPath(id), { force: true }); await rm(`${this.uploadPath(id)}.json`, { force: true }) } catch {}
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/** Raw sheets of a staged upload: the cached parse, or one read + one parse. */
|
|
187
|
+
async stagedSheets(uploadId) {
|
|
188
|
+
const file = this.uploadPath(uploadId)
|
|
189
|
+
const meta = await this.readUploadMeta(uploadId)
|
|
190
|
+
if (!meta?.complete) throw new UserError('nl2sql/invalid', '上传不存在或已过期(暂存文件保留 1 小时),请重新上传')
|
|
191
|
+
if (this.stagedParse?.id === uploadId) return { fileName: meta.fileName, raw: this.stagedParse.raw }
|
|
192
|
+
let buf
|
|
193
|
+
try { buf = await readFile(file) } catch { throw new UserError('nl2sql/invalid', '上传不存在或已过期(暂存文件保留 1 小时),请重新上传') }
|
|
194
|
+
return { fileName: meta.fileName, raw: readSheets(meta.fileName, buf) }
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** Import one or more sheets of an uploaded file into a local file data source. */
|
|
198
|
+
async importUpload({ datasourceId, newName, uploadId, fileName, base64, headerRow = true, sheets: picks, mode = 'create' }) {
|
|
199
|
+
let raw
|
|
200
|
+
if (uploadId) {
|
|
201
|
+
const staged = await this.stagedSheets(uploadId)
|
|
202
|
+
fileName = fileName || staged.fileName
|
|
203
|
+
raw = staged.raw
|
|
204
|
+
}
|
|
205
|
+
let ds = datasourceId ? this.requireDatasource(datasourceId) : undefined
|
|
206
|
+
if (!ds) ds = await this.saveDatasource({ type: 'sqlite', name: newName || String(fileName ?? '本地文件').replace(/\.[^.]+$/, '').slice(0, 60), description: `上传的文件:${fileName}` })
|
|
207
|
+
if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '只能导入到「本地文件」类型的数据源')
|
|
208
|
+
const ds2 = this.requireDatasource(ds.id)
|
|
209
|
+
if (!raw) {
|
|
210
|
+
const buf = decode(base64)
|
|
211
|
+
checkSize(buf.length)
|
|
212
|
+
raw = isText(fileName) ? [{ name: String(fileName).replace(/\.[^.]+$/, ''), rows: parseCsv(buf).rows }] : readXlsx(buf)
|
|
213
|
+
}
|
|
214
|
+
const results = []
|
|
215
|
+
await this.closeDriver(ds2.id)
|
|
216
|
+
for (const sh of raw) {
|
|
217
|
+
const pick = picks?.find(p => p.sheet === sh.name)
|
|
218
|
+
if (picks?.length && !pick) continue
|
|
219
|
+
const rows = sh.rows.filter(r => r.some(v => v !== null && v !== undefined && v !== ''))
|
|
220
|
+
if (!rows.length) continue
|
|
221
|
+
const header = headerRow ? rows[0] : rows[0].map((_, i) => `column_${i + 1}`)
|
|
222
|
+
const body = headerRow ? rows.slice(1) : rows
|
|
223
|
+
const width = Math.max(header.length, ...body.slice(0, 1000).map(r => r.length))
|
|
224
|
+
const strBody = body.map(r => Array.from({ length: width }, (_, i) => r[i] === null || r[i] === undefined ? '' : String(r[i])))
|
|
225
|
+
let cols = inferColumns(Array.from({ length: width }, (_, i) => header[i] ?? ''), strBody)
|
|
226
|
+
if (pick?.columns?.length === cols.length) cols = cols.map((c, i) => ({ ...c, name: pick.columns[i].name || c.name, type: pick.columns[i].type || c.type }))
|
|
227
|
+
const values = strBody.map(r => r.map((v, i) => coerceValue(v, cols[i].type)))
|
|
228
|
+
try {
|
|
229
|
+
const res = importTable({ file: ds2.file, table: pick?.table || sh.name || 'data', columns: cols.map(c => ({ name: c.name, type: c.type })), rows: values, mode: pick?.mode ?? mode, source: fileName })
|
|
230
|
+
results.push({ sheet: sh.name, ...res, ok: true })
|
|
231
|
+
} catch (error) {
|
|
232
|
+
results.push({ sheet: sh.name, ok: false, error: error.message })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (results.some(r => r.ok)) {
|
|
236
|
+
await this.analyzeSchema(ds2.id, { refresh: true })
|
|
237
|
+
if (uploadId) await this.discardUpload(uploadId)
|
|
238
|
+
}
|
|
239
|
+
this.auditAdmin('upload', `${fileName} → ${ds2.name}`)
|
|
240
|
+
await this.save()
|
|
241
|
+
return { datasourceId: ds2.id, datasourceName: ds2.name, results }
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
listFileTables(datasourceId) {
|
|
245
|
+
const ds = this.requireDatasource(datasourceId)
|
|
246
|
+
if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '不是本地文件数据源')
|
|
247
|
+
try { return listTables({ file: ds.file }) } catch { return [] }
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
async dropFileTable({ datasourceId, table }) {
|
|
251
|
+
const ds = this.requireDatasource(datasourceId)
|
|
252
|
+
if (ds.type !== 'sqlite') throw new UserError('nl2sql/invalid', '不是本地文件数据源')
|
|
253
|
+
await this.closeDriver(ds.id)
|
|
254
|
+
dropTable({ file: ds.file, table })
|
|
255
|
+
await this.analyzeSchema(ds.id, { refresh: true })
|
|
256
|
+
return this.listFileTables(ds.id)
|
|
257
|
+
},
|
|
258
|
+
}
|
package/sql-guard.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only SQL admission for MySQL 5.7+, PostgreSQL, Elasticsearch SQL and SQLite.
|
|
3
|
+
*
|
|
4
|
+
* The guard is a syntactic first line of defence; execution additionally runs
|
|
5
|
+
* inside a READ ONLY transaction with a server-side time limit, so a statement
|
|
6
|
+
* the guard misjudges still cannot write.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const ALLOWED_HEADS = {
|
|
10
|
+
mysql: new Set(['SELECT', 'SHOW', 'DESC', 'DESCRIBE', 'EXPLAIN', 'WITH']),
|
|
11
|
+
postgres: new Set(['SELECT', 'SHOW', 'EXPLAIN', 'WITH', 'VALUES', 'TABLE']),
|
|
12
|
+
elasticsearch: new Set(['SELECT', 'SHOW', 'DESC', 'DESCRIBE']),
|
|
13
|
+
sqlite: new Set(['SELECT', 'WITH', 'VALUES', 'EXPLAIN']),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// PostgreSQL functions that write, signal, sleep, or reach the file system.
|
|
17
|
+
const PG_FORBIDDEN = /\b(pg_sleep\w*|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_read_file|pg_read_binary_file|pg_ls_dir|pg_stat_file|lo_\w+|dblink\w*|nextval|setval|set_config|pg_advisory\w*|pg_try_advisory\w*|pg_notify|txid_current|pg_switch_wal|pg_create_\w+|pg_drop_\w+|pg_logical_\w+|copy|query_to_xml\w*|query_to_json\w*|table_to_xml\w*|cursor_to_xml\w*|schema_to_xml\w*|database_to_xml\w*|pg_get_\w*def|pg_get_expr|xmlparse|xmltable|pg_read_\w+|pg_file_\w+|pg_execute\w*|current_setting|pg_settings)\s*\(/i
|
|
18
|
+
|
|
19
|
+
// SQLite: extension loading, file I/O (CLI/fileio shell functions), tokenizer pointer tricks,
|
|
20
|
+
// and statements that touch other database files or the schema/connection state.
|
|
21
|
+
const SQLITE_FORBIDDEN_FN = /\b(load_extension|readfile|writefile|edit|fts3_tokenizer|zipfile|sqlar_\w+|lsmode|randomblob|zeroblob)\s*\(/i
|
|
22
|
+
const SQLITE_FORBIDDEN_STMT = /\b(ATTACH|DETACH|PRAGMA|VACUUM|REINDEX|RETURNING)\b/i
|
|
23
|
+
|
|
24
|
+
const FORBIDDEN = [
|
|
25
|
+
[/\bINTO\s+(OUTFILE|DUMPFILE)\b/i, 'INTO OUTFILE/DUMPFILE is not allowed'],
|
|
26
|
+
[/\bINTO\s+@/i, 'SELECT ... INTO @variable is not allowed'],
|
|
27
|
+
[/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i, 'SELECT ... FOR UPDATE/SHARE takes locks and is not allowed'],
|
|
28
|
+
[/\bLOCK\s+IN\s+SHARE\s+MODE\b/i, 'LOCK IN SHARE MODE is not allowed'],
|
|
29
|
+
[/\b(SLEEP|BENCHMARK|LOAD_FILE|GET_LOCK|RELEASE_LOCK|RELEASE_ALL_LOCKS)\s*\(/i, 'time-wasting or file/lock functions are not allowed'],
|
|
30
|
+
[/\b(INSERT|UPDATE|DELETE|REPLACE|MERGE|CREATE|ALTER|DROP|TRUNCATE|RENAME|GRANT|REVOKE|CALL|HANDLER|LOAD|SET|USE|KILL|FLUSH|RESET|SHUTDOWN|INSTALL|UNINSTALL|PREPARE|EXECUTE|DEALLOCATE|LOCK|UNLOCK|START|BEGIN|COMMIT|ROLLBACK|SAVEPOINT|XA|DO|ANALYZE|OPTIMIZE|REPAIR|CHECKSUM)\b/i, 'only read-only statements are allowed'],
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Per-dialect lexical rules. Getting these exactly right matters: if the guard
|
|
35
|
+
* thinks a quote is escaped when the server does not (or vice versa), text the
|
|
36
|
+
* server executes would be hidden inside a "string".
|
|
37
|
+
* mysql: `-- ` needs whitespace after it, `#` comments, backslash escapes in '…' and "…", `…` identifiers
|
|
38
|
+
* postgres: any `--` comments, no `#`, backslash escapes only inside E'…', $tag$…$tag$ dollar quoting, "…" identifiers
|
|
39
|
+
* sqlite: any `--` comments, no `#`, no backslash escapes, "…" / `…` / […] identifiers
|
|
40
|
+
* elasticsearch: any `--` comments, no `#`, no backslash escapes, "…" identifiers
|
|
41
|
+
*/
|
|
42
|
+
export function dialectRules(dialect = 'mysql') {
|
|
43
|
+
const mysql = dialect === 'mysql'
|
|
44
|
+
return {
|
|
45
|
+
dashDashNeedsSpace: mysql,
|
|
46
|
+
hashComment: mysql,
|
|
47
|
+
backslashEscapes: mysql, // postgres: only E'…' strings (handled separately)
|
|
48
|
+
dollarQuotes: dialect === 'postgres',
|
|
49
|
+
escapeStrings: dialect === 'postgres',
|
|
50
|
+
bracketIdents: dialect === 'sqlite',
|
|
51
|
+
backtickIdents: mysql || dialect === 'sqlite',
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Tokenize SQL into code / string / identifier / comment segments using the
|
|
57
|
+
* dialect's lexical rules. Unterminated strings, identifiers and comments are
|
|
58
|
+
* reported (the server would reject them, and they can hide code).
|
|
59
|
+
* @returns {{ segments: { kind: 'code'|'string'|'ident'|'comment', text: string, start: number, end: number }[], unterminated: boolean, executableComment: boolean }}
|
|
60
|
+
*/
|
|
61
|
+
export function lexSql(sql, { dialect = 'mysql' } = {}) {
|
|
62
|
+
const r = dialectRules(dialect)
|
|
63
|
+
const segments = []
|
|
64
|
+
let unterminated = false
|
|
65
|
+
let executableComment = false
|
|
66
|
+
let code = ''
|
|
67
|
+
let codeStart = 0
|
|
68
|
+
const flush = (end) => { if (code) segments.push({ kind: 'code', text: code, start: codeStart, end }); code = '' }
|
|
69
|
+
const push = (kind, start, end) => { flush(start); segments.push({ kind, text: sql.slice(start, end), start, end }); codeStart = end }
|
|
70
|
+
let i = 0
|
|
71
|
+
while (i < sql.length) {
|
|
72
|
+
const c = sql[i]
|
|
73
|
+
const n = sql[i + 1]
|
|
74
|
+
// -- comments
|
|
75
|
+
if (c === '-' && n === '-' && (!r.dashDashNeedsSpace || sql[i + 2] === undefined || /\s/.test(sql[i + 2]))) {
|
|
76
|
+
const start = i
|
|
77
|
+
while (i < sql.length && sql[i] !== '\n') i++
|
|
78
|
+
push('comment', start, i)
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (c === '#' && r.hashComment) {
|
|
82
|
+
const start = i
|
|
83
|
+
while (i < sql.length && sql[i] !== '\n') i++
|
|
84
|
+
push('comment', start, i)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (c === '/' && n === '*') {
|
|
88
|
+
const start = i
|
|
89
|
+
if (sql[i + 2] === '!' && dialect === 'mysql') executableComment = true
|
|
90
|
+
// PostgreSQL block comments nest.
|
|
91
|
+
let depth = 1
|
|
92
|
+
i += 2
|
|
93
|
+
while (i < sql.length && depth > 0) {
|
|
94
|
+
if (dialect === 'postgres' && sql[i] === '/' && sql[i + 1] === '*') { depth++; i += 2; continue }
|
|
95
|
+
if (sql[i] === '*' && sql[i + 1] === '/') { depth--; i += 2; continue }
|
|
96
|
+
i++
|
|
97
|
+
}
|
|
98
|
+
if (depth > 0) unterminated = true
|
|
99
|
+
push('comment', start, i)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
// PostgreSQL dollar quoting: $$…$$ or $tag$…$tag$ (tag cannot start with a digit: $1 is a parameter).
|
|
103
|
+
if (c === '$' && r.dollarQuotes) {
|
|
104
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i))
|
|
105
|
+
if (m && !/[A-Za-z0-9_]/.test(sql[i - 1] ?? '')) {
|
|
106
|
+
const tag = m[0]
|
|
107
|
+
const start = i
|
|
108
|
+
const close = sql.indexOf(tag, i + tag.length)
|
|
109
|
+
if (close < 0) { unterminated = true; i = sql.length } else i = close + tag.length
|
|
110
|
+
push('string', start, i)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const isEString = r.escapeStrings && (c === 'E' || c === 'e') && n === '\'' && !/[A-Za-z0-9_$]/.test(sql[i - 1] ?? '')
|
|
115
|
+
if (c === '\'' || isEString || c === '"' || (c === '`' && r.backtickIdents) || (c === '[' && r.bracketIdents)) {
|
|
116
|
+
const start = i
|
|
117
|
+
if (isEString) i++
|
|
118
|
+
const q = sql[i]
|
|
119
|
+
const closeCh = q === '[' ? ']' : q
|
|
120
|
+
const backslash = q === '\'' || q === '"' ? (r.backslashEscapes || isEString) : false
|
|
121
|
+
i++
|
|
122
|
+
let closed = false
|
|
123
|
+
while (i < sql.length) {
|
|
124
|
+
if (backslash && sql[i] === '\\') { i += 2; continue }
|
|
125
|
+
if (sql[i] === closeCh) {
|
|
126
|
+
if (closeCh !== ']' && sql[i + 1] === closeCh) { i += 2; continue }
|
|
127
|
+
closed = true
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
i++
|
|
131
|
+
}
|
|
132
|
+
if (!closed) unterminated = true
|
|
133
|
+
i = Math.min(sql.length, i + 1)
|
|
134
|
+
const isString = q === '\'' || (q === '"' && dialect === 'mysql')
|
|
135
|
+
push(isString ? 'string' : 'ident', start, i)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (!code) codeStart = i
|
|
139
|
+
code += c
|
|
140
|
+
i++
|
|
141
|
+
}
|
|
142
|
+
flush(sql.length)
|
|
143
|
+
return { segments, unterminated, executableComment }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Replace string literals, quoted identifiers and comments with placeholders so
|
|
148
|
+
* the keyword checks only see SQL structure.
|
|
149
|
+
* @returns {{ code: string, executableComment: boolean, unterminated: boolean }}
|
|
150
|
+
*/
|
|
151
|
+
export function stripSql(sql, { dialect = 'mysql' } = {}) {
|
|
152
|
+
const { segments, unterminated, executableComment } = lexSql(sql, { dialect })
|
|
153
|
+
const code = segments.map(s => s.kind === 'code' ? s.text : s.kind === 'string' ? ' _str_ ' : s.kind === 'ident' ? ' _ident_ ' : ' ').join('')
|
|
154
|
+
return { code, executableComment, unterminated }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate one statement and return its normalized form.
|
|
159
|
+
* @param {string} input - user or model SQL.
|
|
160
|
+
* @returns {{ sql: string, head: string, hasLimit: boolean }}
|
|
161
|
+
* @throws Error with a model-readable reason when the statement is refused.
|
|
162
|
+
*/
|
|
163
|
+
export function admitSql(input, { dialect = 'mysql' } = {}) {
|
|
164
|
+
if (typeof input !== 'string' || input.trim() === '') throw new Error('SQL is empty')
|
|
165
|
+
let sql = input.trim()
|
|
166
|
+
while (sql.endsWith(';')) sql = sql.slice(0, -1).trimEnd()
|
|
167
|
+
const { code, executableComment, unterminated } = stripSql(sql, { dialect })
|
|
168
|
+
if (executableComment) throw new Error('MySQL executable comments (/*! ... */) are not allowed')
|
|
169
|
+
if (unterminated) throw new Error('unterminated string, identifier or comment')
|
|
170
|
+
if (code.includes(';')) throw new Error('only one statement may be executed at a time')
|
|
171
|
+
const head = (code.trim().match(/^\(*\s*([A-Za-z]+)/)?.[1] ?? '').toUpperCase()
|
|
172
|
+
const allowed = ALLOWED_HEADS[dialect] ?? ALLOWED_HEADS.mysql
|
|
173
|
+
if (!allowed.has(head)) throw new Error(`only read-only statements (${[...allowed].join(' / ')}) are allowed (got ${head || 'unknown'})`)
|
|
174
|
+
if (dialect === 'postgres') {
|
|
175
|
+
if (PG_FORBIDDEN.test(code)) throw new Error('this PostgreSQL function is not allowed in read-only queries')
|
|
176
|
+
if (/\bINTO\b/i.test(code) && head !== 'EXPLAIN') throw new Error('SELECT ... INTO is not allowed')
|
|
177
|
+
if (/\bEXPLAIN\b[^)]*\bANALY[SZ]E\b/i.test(code)) throw new Error('EXPLAIN ANALYZE executes the statement and is not allowed')
|
|
178
|
+
}
|
|
179
|
+
if (dialect === 'sqlite') {
|
|
180
|
+
if (head === 'EXPLAIN' && !/^\(*\s*EXPLAIN\s+QUERY\s+PLAN\b/i.test(code.trim())) throw new Error('only EXPLAIN QUERY PLAN is allowed')
|
|
181
|
+
if (SQLITE_FORBIDDEN_FN.test(code)) throw new Error('this SQLite function is not allowed in read-only queries')
|
|
182
|
+
const m = code.match(SQLITE_FORBIDDEN_STMT)
|
|
183
|
+
if (m) throw new Error(`${m[1].toUpperCase()} is not allowed; only read-only SELECT queries are allowed`)
|
|
184
|
+
if (/\bINTO\b/i.test(code)) throw new Error('INTO is not allowed in read-only queries')
|
|
185
|
+
}
|
|
186
|
+
for (const [pattern, reason] of FORBIDDEN) {
|
|
187
|
+
for (const m of code.matchAll(new RegExp(pattern.source, 'gi'))) {
|
|
188
|
+
// REPLACE()/INSERT() are string functions and CHARACTER SET is a clause;
|
|
189
|
+
// these spellings stay allowed inside a SELECT.
|
|
190
|
+
if (isFunctionCall(code, m.index, m[0])) continue
|
|
191
|
+
if (/^SET$/i.test(m[1] ?? '') && /CHARACTER\s+$/i.test(code.slice(Math.max(0, m.index - 12), m.index))) continue
|
|
192
|
+
if (/^ANALYZE$/i.test(m[1] ?? '') && head === 'EXPLAIN' && dialect === 'mysql') continue
|
|
193
|
+
if (/^LOCK$/i.test(m[1] ?? '') && /FOR\s+(UPDATE|SHARE|NO\s+KEY|KEY)\s*$/i.test(code.slice(Math.max(0, m.index - 20), m.index))) continue
|
|
194
|
+
throw new Error(reason)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const hasLimit = /\bLIMIT\s+\d+(\s*(,|OFFSET)\s*\d+)?\s*$/i.test(code.trim())
|
|
198
|
+
return { sql, head, hasLimit }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isFunctionCall(code, index, word) {
|
|
202
|
+
const name = word.trim()
|
|
203
|
+
if (!/^(REPLACE|INSERT|LOCK|SET|LOAD)$/i.test(name)) return false
|
|
204
|
+
return /^\s*\(/.test(code.slice(index + word.length))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Prepare a SELECT for execution: cap rows and attach the server time limit.
|
|
209
|
+
* @param {{ sql: string, head: string, hasLimit: boolean }} admitted
|
|
210
|
+
* @param {{ maxRows: number, timeoutMs: number, useHint?: boolean, dialect?: string }} limits
|
|
211
|
+
* @returns {string}
|
|
212
|
+
*/
|
|
213
|
+
export function prepareSql(admitted, limits) {
|
|
214
|
+
let sql = admitted.sql
|
|
215
|
+
const dialect = limits.dialect ?? 'mysql'
|
|
216
|
+
if (admitted.head === 'SELECT' || (admitted.head === 'WITH' && (dialect === 'postgres' || dialect === 'sqlite'))) {
|
|
217
|
+
if (dialect === 'mysql' && limits.useHint !== false && !/MAX_EXECUTION_TIME/i.test(sql)) sql = sql.replace(/^(\(*\s*)SELECT\b/i, `$1SELECT /*+ MAX_EXECUTION_TIME(${Math.max(1, Math.floor(limits.timeoutMs))}) */`)
|
|
218
|
+
// Elasticsearch pages with fetch_size instead; PostgreSQL, SQLite and MySQL get a LIMIT one row past the cap.
|
|
219
|
+
if (!admitted.hasLimit && dialect !== 'elasticsearch') sql = `${sql}\nLIMIT ${limits.maxRows + 1}`
|
|
220
|
+
}
|
|
221
|
+
return sql
|
|
222
|
+
}
|