@bakery-framework/plugin-dashboard 2.0.0-alpha.5 → 2.0.0-alpha.6
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 +4 -4
- package/src/client/dashboard.ts +2 -65
- package/src/client/parts/effects.ts +2 -2
- package/src/client/parts/utils.ts +13 -40
- package/src/components/DBBrowser.tsx +36 -352
- package/src/endpoints/database.ts +15 -179
- package/src/setup.ts +29 -12
- package/src/client/parts/database.ts +0 -1176
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import { getElapsed } from '@bakery-framework/core/logger'
|
|
2
|
-
import { processBody } from '@bakery-framework/core/utils'
|
|
3
1
|
import type { JsonResponseData } from '@bakery-framework/core/utils/common'
|
|
4
|
-
import {
|
|
2
|
+
import { Try } from '@bakery-framework/core/utils/common'
|
|
5
3
|
import { response } from '@bakery-framework/core/utils/http'
|
|
6
4
|
import { connection } from '@bakery-framework/orm/connection'
|
|
7
5
|
|
|
6
|
+
/**
|
|
7
|
+
* What is left of the console's database surface: two read-only endpoints.
|
|
8
|
+
*
|
|
9
|
+
* The grid editor and the SQL prompt that used to live here are retired —
|
|
10
|
+
* `@bakery-framework/plugin-db-explorer` does the same work with an access
|
|
11
|
+
* model instead of an environment flag, and the console's Database tab is now
|
|
12
|
+
* a link to it. Gone with them: `handleQuery`, `handleExecuteAction`, the
|
|
13
|
+
* statement classifier in `sql-classify.ts`, and `DASHBOARD_ALLOW_WRITES`.
|
|
14
|
+
*
|
|
15
|
+
* The flag is not deprecated, it is *absent*. Nothing here reads it, so
|
|
16
|
+
* setting it has no effect at all — which is the honest state for a console
|
|
17
|
+
* that can no longer write.
|
|
18
|
+
*/
|
|
19
|
+
|
|
8
20
|
export async function handleSchema(): Promise<JsonResponseData<unknown>> {
|
|
9
21
|
return await Try.return(
|
|
10
22
|
async () => response.json.success('success', await connection.getSchema()),
|
|
@@ -33,179 +45,3 @@ export async function handleTableData(
|
|
|
33
45
|
(error: any) => response.json.error(400, error.message),
|
|
34
46
|
)
|
|
35
47
|
}
|
|
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
|
-
}
|
package/src/setup.ts
CHANGED
|
@@ -26,12 +26,7 @@ import {
|
|
|
26
26
|
// the tree, allow-listed by name in `tests/conventions.test.ts`.
|
|
27
27
|
import { setupAnalytics } from '@bakery-framework/plugin-analytics/setup'
|
|
28
28
|
import { isAnalyticsAuthorized } from '@bakery-framework/plugin-analytics/stats'
|
|
29
|
-
import {
|
|
30
|
-
handleExecuteAction,
|
|
31
|
-
handleQuery,
|
|
32
|
-
handleSchema,
|
|
33
|
-
handleTableData,
|
|
34
|
-
} from './endpoints/database'
|
|
29
|
+
import { handleSchema, handleTableData } from './endpoints/database'
|
|
35
30
|
import {
|
|
36
31
|
handleDeleteSession,
|
|
37
32
|
handleGetSessions,
|
|
@@ -209,9 +204,14 @@ async function checkAuthMiddleware(req: Request, path: string) {
|
|
|
209
204
|
*
|
|
210
205
|
* Necessary but **not sufficient on its own**. `SAFE_METHODS` exempts GET, and
|
|
211
206
|
* `processBody` reads a GET's query string as the body, so a plain
|
|
212
|
-
* `<img src="/api/_dashboard/
|
|
213
|
-
*
|
|
214
|
-
*
|
|
207
|
+
* `<img src="/api/_dashboard/sessions/delete?id=victim">` sails past this
|
|
208
|
+
* guard. The mutating keys in the table below are method-qualified for exactly
|
|
209
|
+
* that reason; neither half closes the hole alone.
|
|
210
|
+
*
|
|
211
|
+
* The example used to be `execute-action?action=truncate`, which was the worst
|
|
212
|
+
* of them — a link that emptied a table. That endpoint is gone with the grid
|
|
213
|
+
* editor, and the two session routes are what is left to protect. The hazard
|
|
214
|
+
* is unchanged; only the blast radius shrank.
|
|
215
215
|
*/
|
|
216
216
|
function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
|
|
217
217
|
const reason = checkCsrf(req, url)
|
|
@@ -243,8 +243,6 @@ const dashboardRoutes = {
|
|
|
243
243
|
'POST /api/_dashboard/sessions/update': req => handleUpdateSession(req),
|
|
244
244
|
'/api/_dashboard/schema': () => handleSchema(),
|
|
245
245
|
'/api/_dashboard/table-data': (_req, url) => handleTableData(url),
|
|
246
|
-
'POST /api/_dashboard/query': req => handleQuery(req),
|
|
247
|
-
'POST /api/_dashboard/execute-action': req => handleExecuteAction(req),
|
|
248
246
|
} satisfies PluginRouteTable
|
|
249
247
|
|
|
250
248
|
const dispatchDashboardRoute = routeTable(dashboardRoutes)
|
|
@@ -279,5 +277,24 @@ export async function handleDashboardRequest(
|
|
|
279
277
|
const csrfBlock = checkCsrfMiddleware(req, url)
|
|
280
278
|
if (csrfBlock) return csrfBlock
|
|
281
279
|
|
|
282
|
-
|
|
280
|
+
const result = await dispatchDashboardRoute(req, url)
|
|
281
|
+
if (result) return result
|
|
282
|
+
|
|
283
|
+
// `dispatch` answers `null` for a path no key matched, and a handler
|
|
284
|
+
// returning `null` means "not mine" — which core turns into **204 No
|
|
285
|
+
// Content**. Every unmatched request under this namespace therefore answered
|
|
286
|
+
// 204: a status that reads as success and carries nothing.
|
|
287
|
+
//
|
|
288
|
+
// Retiring the write endpoints is what made that matter. A script still
|
|
289
|
+
// posting to `/api/_dashboard/execute-action` was told 204, took it for
|
|
290
|
+
// success, and silently changed nothing. For a route that has been deleted
|
|
291
|
+
// that is the worst available answer.
|
|
292
|
+
//
|
|
293
|
+
// **Only under `/api/`, and that boundary is load-bearing.** The stylesheet
|
|
294
|
+
// is served by `mountRoutes`, not by a key in the table above, so `null` is
|
|
295
|
+
// precisely how `/_dashboard/style.css` *falls through* to the static
|
|
296
|
+
// pipeline. A blanket 404 here claims it and the console loads unstyled —
|
|
297
|
+
// which is what happened when this was first written without the condition.
|
|
298
|
+
if (path.startsWith('/api/')) return response.error('Not Found', 404)
|
|
299
|
+
return null
|
|
283
300
|
}
|