@bakery-framework/plugin-dashboard 2.0.0-alpha.5 → 2.0.0-alpha.7

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.
@@ -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
- }