@bakery-framework/plugin-dashboard 2.0.0-alpha.1 → 2.0.0-alpha.11
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/package.json +5 -4
- package/src/client/dashboard.ts +2 -65
- package/src/client/parts/effects.ts +2 -2
- package/src/client/parts/stats.ts +105 -23
- package/src/client/parts/utils.ts +13 -40
- package/src/components/DBBrowser.tsx +41 -352
- package/src/index.ts +45 -3
- package/src/setup.ts +71 -40
- package/src/shell.tsx +85 -21
- package/src/authorize.ts +0 -76
- package/src/client/parts/database.ts +0 -1176
- package/src/endpoints/database.ts +0 -211
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import { getElapsed } from '@bakery-framework/core/logger'
|
|
2
|
-
import { processBody } from '@bakery-framework/core/utils'
|
|
3
|
-
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
4
|
-
import { is, Try } from '@bakery-framework/core/utils/common'
|
|
5
|
-
import { response } from '@bakery-framework/core/utils/http'
|
|
6
|
-
import { connection } from '@bakery-framework/orm/connection'
|
|
7
|
-
|
|
8
|
-
export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
9
|
-
return await Try.return(
|
|
10
|
-
async () => response.json.success('success', await connection.getSchema()),
|
|
11
|
-
() => response.json.error(500, 'Failed to retrieve schema details'),
|
|
12
|
-
)
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function handleTableData(
|
|
16
|
-
url: URL,
|
|
17
|
-
): Promise<JsonResponseData<unknown>> {
|
|
18
|
-
const tableName = url.searchParams.get('tableName')
|
|
19
|
-
if (!tableName || !/^[a-zA-Z0-9_]+$/.test(tableName))
|
|
20
|
-
return response.json.error(400, 'Invalid table name')
|
|
21
|
-
|
|
22
|
-
return await Try.return(
|
|
23
|
-
async () => {
|
|
24
|
-
const data = await connection.getData(tableName, {
|
|
25
|
-
page: parseInt(url.searchParams.get('page') || '1', 10),
|
|
26
|
-
pageSize: parseInt(url.searchParams.get('pageSize') || '50', 10),
|
|
27
|
-
sortBy: url.searchParams.get('sortBy'),
|
|
28
|
-
sortOrder: url.searchParams.get('sortOrder') || 'ASC',
|
|
29
|
-
filters: JSON.parse(url.searchParams.get('filters') || '{}'),
|
|
30
|
-
})
|
|
31
|
-
return response.json.success('success', data)
|
|
32
|
-
},
|
|
33
|
-
(error: any) => response.json.error(400, error.message),
|
|
34
|
-
)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Statements that reach outside the database itself. SQLite's `ATTACH` plus
|
|
39
|
-
* `VACUUM INTO` is an arbitrary file write, which turns any dashboard session
|
|
40
|
-
* (or any XSS in the dashboard origin) into host filesystem access.
|
|
41
|
-
*/
|
|
42
|
-
const RX_OUT_OF_BAND_SQL = /\b(attach|detach|vacuum\s+into)\b/i
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Writes from the dashboard need an explicit opt-in.
|
|
46
|
-
*
|
|
47
|
-
* This gate used to sit only in `handleQuery`, so `DELETE FROM t` typed into
|
|
48
|
-
* the SQL box was refused while the grid's Delete and Truncate buttons — which
|
|
49
|
-
* reach the same rows through `execute-action`, with no SQL to inspect — were
|
|
50
|
-
* not. Half a control is worse than none: the production and security
|
|
51
|
-
* checklists both tell operators that leaving `DASHBOARD_ALLOW_WRITES` unset
|
|
52
|
-
* keeps the console read-only, and that was only ever true of one of its two
|
|
53
|
-
* write paths.
|
|
54
|
-
*
|
|
55
|
-
* Read at call time, not at module load: tests set and restore it, and the
|
|
56
|
-
* flag is a deployment decision rather than a build one.
|
|
57
|
-
*/
|
|
58
|
-
function writesEnabled(): boolean {
|
|
59
|
-
return process.env.DASHBOARD_ALLOW_WRITES === '1'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export async function handleQuery(
|
|
63
|
-
req: Request,
|
|
64
|
-
): Promise<JsonResponseData<unknown>> {
|
|
65
|
-
const body = await processBody(req)
|
|
66
|
-
if (!is.string(body?.sql))
|
|
67
|
-
return response.json.error(400, 'Invalid SQL query')
|
|
68
|
-
|
|
69
|
-
if (RX_OUT_OF_BAND_SQL.test(body.sql)) {
|
|
70
|
-
return response.json.error(
|
|
71
|
-
403,
|
|
72
|
-
'ATTACH / DETACH / VACUUM INTO are not permitted from the dashboard.',
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const sqlLower = body.sql.trim().toLowerCase()
|
|
77
|
-
const isSelect = /^(select|with|show|describe|pragma|explain)/.test(sqlLower)
|
|
78
|
-
|
|
79
|
-
// Writes require an explicit opt-in; the browser console should not be a
|
|
80
|
-
// one-keystroke path to DROP TABLE on a production database.
|
|
81
|
-
if (!isSelect && !writesEnabled()) {
|
|
82
|
-
return response.json.error(
|
|
83
|
-
403,
|
|
84
|
-
'Write statements are disabled. Set DASHBOARD_ALLOW_WRITES=1 to enable them.',
|
|
85
|
-
)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const start = Bun.nanoseconds()
|
|
89
|
-
|
|
90
|
-
return await Try.return(
|
|
91
|
-
async () => {
|
|
92
|
-
const result = isSelect
|
|
93
|
-
? { rows: await connection.query(body.sql).all(), isSelect: true }
|
|
94
|
-
: {
|
|
95
|
-
rows: [
|
|
96
|
-
(({ lastInsertRowid, changes }) => ({
|
|
97
|
-
lastInsertRowid,
|
|
98
|
-
changes,
|
|
99
|
-
}))(await connection.query(body.sql).run()),
|
|
100
|
-
],
|
|
101
|
-
isSelect: false,
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return response.json.success('success', {
|
|
105
|
-
...result,
|
|
106
|
-
time: getElapsed(start),
|
|
107
|
-
})
|
|
108
|
-
},
|
|
109
|
-
(error: any) =>
|
|
110
|
-
response.json.error(400, error.message, { time: getElapsed(start) }),
|
|
111
|
-
)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Every branch answers with the JSON envelope — never a `Response` — so the
|
|
116
|
-
* table is typed as such. `data` stays `unknown`: these payloads are arbitrary
|
|
117
|
-
* database rows, and `unknown` says that honestly where `any` would have let
|
|
118
|
-
* the lie back in.
|
|
119
|
-
*/
|
|
120
|
-
const executeActionHandlers: Record<
|
|
121
|
-
string,
|
|
122
|
-
(body: any, connection: any) => Promise<JsonResponseData<unknown>>
|
|
123
|
-
> = {
|
|
124
|
-
'delete-row': async (body, connection) => {
|
|
125
|
-
if (body.rowid == null) return response.json.error(400, 'Invalid row ID')
|
|
126
|
-
return await Try.return(
|
|
127
|
-
async () => {
|
|
128
|
-
await connection.remove(body.tableName, body.rowid)
|
|
129
|
-
return response.json.success('Row deleted')
|
|
130
|
-
},
|
|
131
|
-
(e: any) => response.json.error(400, e.message),
|
|
132
|
-
)
|
|
133
|
-
},
|
|
134
|
-
truncate: async (body, connection) => {
|
|
135
|
-
return await Try.return(
|
|
136
|
-
async () => {
|
|
137
|
-
await connection.truncate(body.tableName)
|
|
138
|
-
return response.json.success('Table truncated')
|
|
139
|
-
},
|
|
140
|
-
(e: any) => response.json.error(400, e.message),
|
|
141
|
-
)
|
|
142
|
-
},
|
|
143
|
-
'insert-row': async (body, connection) => {
|
|
144
|
-
if (!body.row || typeof body.row !== 'object')
|
|
145
|
-
return response.json.error(400, 'Invalid row data')
|
|
146
|
-
return await Try.return(
|
|
147
|
-
async () => {
|
|
148
|
-
await connection.insert(body.tableName, body.row)
|
|
149
|
-
return response.json.success('Row inserted')
|
|
150
|
-
},
|
|
151
|
-
(e: any) => response.json.error(400, e.message),
|
|
152
|
-
)
|
|
153
|
-
},
|
|
154
|
-
'update-row': async (body, connection) => {
|
|
155
|
-
if (
|
|
156
|
-
!body.row ||
|
|
157
|
-
!is.object(body.row) ||
|
|
158
|
-
Array.isArray(body.row) ||
|
|
159
|
-
body.rowid == null
|
|
160
|
-
) {
|
|
161
|
-
return response.json.error(400, 'Invalid data or row ID')
|
|
162
|
-
}
|
|
163
|
-
return await Try.return(
|
|
164
|
-
async () => {
|
|
165
|
-
await connection.update(body.tableName, body.rowid, body.row)
|
|
166
|
-
return response.json.success('Row updated')
|
|
167
|
-
},
|
|
168
|
-
(e: any) => response.json.error(400, e.message),
|
|
169
|
-
)
|
|
170
|
-
},
|
|
171
|
-
'import-csv': async (body, connection) => {
|
|
172
|
-
if (typeof body.csvContent !== 'string')
|
|
173
|
-
return response.json.error(400, 'Invalid CSV')
|
|
174
|
-
return await Try.return(
|
|
175
|
-
async () => {
|
|
176
|
-
const info = await connection.importCSV(body.tableName, body.csvContent)
|
|
177
|
-
return response.json.success(`Imported ${info.changes} rows`, {
|
|
178
|
-
info,
|
|
179
|
-
})
|
|
180
|
-
},
|
|
181
|
-
(e: any) => response.json.error(400, e.message),
|
|
182
|
-
)
|
|
183
|
-
},
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export async function handleExecuteAction(
|
|
187
|
-
req: Request,
|
|
188
|
-
): Promise<JsonResponseData<unknown>> {
|
|
189
|
-
// Every entry in `executeActionHandlers` writes — truncate and delete-row
|
|
190
|
-
// destroy data outright — so the gate covers the endpoint rather than being
|
|
191
|
-
// repeated per action. Checked before the body is even read.
|
|
192
|
-
if (!writesEnabled()) {
|
|
193
|
-
return response.json.error(
|
|
194
|
-
403,
|
|
195
|
-
'Dashboard writes are disabled. Set DASHBOARD_ALLOW_WRITES=1 to enable them.',
|
|
196
|
-
)
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const body = await processBody(req)
|
|
200
|
-
const { action, tableName } = body || {}
|
|
201
|
-
|
|
202
|
-
if (!is.string(tableName) || !/^[a-zA-Z0-9_]+$/.test(tableName)) {
|
|
203
|
-
return response.json.error(400, 'Invalid table name')
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const handler = executeActionHandlers[action]
|
|
207
|
-
if (handler) {
|
|
208
|
-
return await handler(body, connection)
|
|
209
|
-
}
|
|
210
|
-
return response.json.error(400, 'Unknown action')
|
|
211
|
-
}
|