@stacksjs/defaults 0.70.226 → 0.70.228
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/app/Actions/Dashboard/Models/ModelDestroyAction.ts +36 -0
- package/app/Actions/Dashboard/Models/ModelShowAction.ts +256 -49
- package/app/Actions/Dashboard/Models/ModelUpdateAction.ts +59 -0
- package/app/Actions/Dashboard/Models/model-write.ts +52 -0
- package/functions/dashboard-api.ts +69 -0
- package/ide/vscode/package.json +1 -1
- package/package.json +1 -1
- package/routes/dashboard-api.ts +10 -3
- package/views/dashboard/models/[model].stx +472 -92
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { parseRowId, resolveWritableModel } from './model-write'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `DELETE /api/dashboard/models/{slug}/{id}`.
|
|
6
|
+
*
|
|
7
|
+
* Deletes one row from the dashboard's generic model browser. Goes through
|
|
8
|
+
* the model rather than the table so soft deletes, observers and cascade
|
|
9
|
+
* behaviour all apply exactly as they would anywhere else in the app.
|
|
10
|
+
*/
|
|
11
|
+
export default new Action({
|
|
12
|
+
name: 'Dashboard Model Destroy',
|
|
13
|
+
description: 'Deletes one row of a model from the dashboard model browser.',
|
|
14
|
+
method: 'DELETE',
|
|
15
|
+
apiResponse: true,
|
|
16
|
+
async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string, id?: string } } }) {
|
|
17
|
+
const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''))
|
|
18
|
+
if ('error' in resolved)
|
|
19
|
+
return { ok: false, error: resolved.error }
|
|
20
|
+
|
|
21
|
+
const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
|
|
22
|
+
if (id === null)
|
|
23
|
+
return { ok: false, error: 'A numeric row id is required.' }
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const row = await resolved.Model.find(id)
|
|
27
|
+
if (!row)
|
|
28
|
+
return { ok: false, error: `${resolved.modelName} ${id} not found.` }
|
|
29
|
+
await row.delete()
|
|
30
|
+
return { ok: true, id }
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -1,37 +1,61 @@
|
|
|
1
1
|
import { Action } from '@stacksjs/actions'
|
|
2
|
-
import {
|
|
2
|
+
import { request } from '@stacksjs/router'
|
|
3
|
+
import { loadModel, safeGet } from '../../../../resources/functions/dashboard/data'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* `GET /api/dashboard/models/{slug}` (stacksjs/stacks#1838).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
* `views/dashboard/models/[model].stx`
|
|
9
|
-
*
|
|
10
|
-
* the #1838 sweep moved the ORM lookup + raw-SQLite fallback into an
|
|
11
|
-
* action so the page can stay `<script client>`-only.
|
|
8
|
+
* The query engine behind the dashboard's generic model browser
|
|
9
|
+
* (`views/dashboard/models/[model].stx`) — the page every model gets for
|
|
10
|
+
* free, whether or not someone hand-built a dedicated view for it.
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* that don't have a matching ORM file (rare; usually a lookup
|
|
18
|
-
* table or userland-only schema).
|
|
12
|
+
* Everything is resolved server-side: paging, sorting, a global search
|
|
13
|
+
* across text columns, and per-column equality filters. The page used to
|
|
14
|
+
* receive the first 50 rows and nothing else, so its pagination controls
|
|
15
|
+
* were inert and a table with real volume was unusable.
|
|
19
16
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* Model resolution order:
|
|
18
|
+
* 1. `globalThis[Name]` — @stacksjs/orm injects every registered model
|
|
19
|
+
* as a global, and that is the same object the rest of the dashboard
|
|
20
|
+
* queries, so scopes, casts and accessors all apply.
|
|
21
|
+
* 2. `loadModel(Name)` — path-map lookup, covers models the ORM has not
|
|
22
|
+
* registered as a global.
|
|
23
|
+
* 3. Raw SQLite — last resort for a table with no model file at all
|
|
24
|
+
* (lookup/pivot tables, or a schema added outside the ORM).
|
|
22
25
|
*/
|
|
23
26
|
|
|
27
|
+
interface ColumnMeta {
|
|
28
|
+
name: string
|
|
29
|
+
label: string
|
|
30
|
+
/** Coarse kind used by the table to pick an alignment and a filter control. */
|
|
31
|
+
type: 'number' | 'boolean' | 'date' | 'json' | 'text'
|
|
32
|
+
}
|
|
33
|
+
|
|
24
34
|
interface ResponseShape {
|
|
25
35
|
modelName: string
|
|
26
36
|
tableName: string
|
|
27
37
|
rows: Array<Record<string, unknown>>
|
|
28
38
|
columns: string[]
|
|
29
39
|
displayColumns: string[]
|
|
40
|
+
columnMeta: ColumnMeta[]
|
|
41
|
+
searchable: string[]
|
|
30
42
|
totalCount: number
|
|
43
|
+
page: number
|
|
44
|
+
perPage: number
|
|
45
|
+
lastPage: number
|
|
46
|
+
sort: string
|
|
47
|
+
dir: 'asc' | 'desc'
|
|
48
|
+
q: string
|
|
49
|
+
filters: Record<string, string>
|
|
31
50
|
source: 'orm' | 'sqlite-fallback'
|
|
51
|
+
/** False for tables reached through the SQLite fallback: no model, no writes. */
|
|
52
|
+
writable: boolean
|
|
32
53
|
error: string | null
|
|
33
54
|
}
|
|
34
55
|
|
|
56
|
+
const DEFAULT_PER_PAGE = 25
|
|
57
|
+
const MAX_PER_PAGE = 200
|
|
58
|
+
|
|
35
59
|
function slugToPascal(str: string): string {
|
|
36
60
|
return str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
|
|
37
61
|
}
|
|
@@ -46,85 +70,268 @@ function pluralize(word: string): string {
|
|
|
46
70
|
return `${word}s`
|
|
47
71
|
}
|
|
48
72
|
|
|
49
|
-
|
|
50
|
-
|
|
73
|
+
function humanize(column: string): string {
|
|
74
|
+
return column
|
|
75
|
+
.replace(/_/g, ' ')
|
|
76
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
77
|
+
.replace(/\bId\b/g, 'ID')
|
|
78
|
+
.replace(/\bUrl\b/g, 'URL')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Hide credential / token columns + private fields by default. These are
|
|
82
|
+
// dropped from the response body AND from the sort/filter allowlist: a
|
|
83
|
+
// column you cannot see is still an enumeration oracle if you can sort or
|
|
84
|
+
// filter by it.
|
|
85
|
+
const HIDDEN_COLUMNS = new Set(['password', 'remember_token', 'api_token', 'access_token', 'refresh_token', 'secret', 'two_factor_secret'])
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Column identifiers reach the query builder as raw, unquoted SQL, so only
|
|
89
|
+
* word-shaped names from the table's own column list may ever be used.
|
|
90
|
+
*/
|
|
91
|
+
function isSafeColumn(name: string, allowed: Set<string>): boolean {
|
|
92
|
+
return /^\w+$/.test(name) && allowed.has(name)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function inferType(values: unknown[]): ColumnMeta['type'] {
|
|
96
|
+
const sample = values.find(v => v !== null && v !== undefined)
|
|
97
|
+
if (sample === undefined) return 'text'
|
|
98
|
+
if (typeof sample === 'boolean') return 'boolean'
|
|
99
|
+
if (typeof sample === 'number') return 'number'
|
|
100
|
+
if (typeof sample === 'object') return 'json'
|
|
101
|
+
return 'text'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function typeForColumn(column: string, values: unknown[]): ColumnMeta['type'] {
|
|
105
|
+
if (column === 'id' || column.endsWith('_id')) return 'number'
|
|
106
|
+
if (column.endsWith('_at') || column.endsWith('_date')) return 'date'
|
|
107
|
+
if (column.startsWith('is_') || column.startsWith('has_')) return 'boolean'
|
|
108
|
+
return inferType(values)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function queryParams(): URLSearchParams {
|
|
112
|
+
const query = ((request as any).query || {}) as Record<string, string | string[] | undefined>
|
|
113
|
+
const params = new URLSearchParams()
|
|
114
|
+
for (const [key, value] of Object.entries(query)) {
|
|
115
|
+
if (value === undefined) continue
|
|
116
|
+
params.set(key, Array.isArray(value) ? String(value[0]) : String(value))
|
|
117
|
+
}
|
|
118
|
+
return params
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `filters` arrives as a JSON object so column names stay unambiguous. */
|
|
122
|
+
function parseFilters(raw: string | null): Record<string, string> {
|
|
123
|
+
if (!raw) return {}
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
126
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
127
|
+
const out: Record<string, string> = {}
|
|
128
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
129
|
+
if (value === null || value === undefined || value === '') continue
|
|
130
|
+
out[key] = String(value)
|
|
131
|
+
}
|
|
132
|
+
return out
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Signals that a free-text search spans several columns, which the query
|
|
141
|
+
* builder cannot express (`whereLike` is single-column and `orWhere` only
|
|
142
|
+
* takes equality pairs). The SQLite path builds that OR with bound
|
|
143
|
+
* parameters instead. Distinct from a real failure so it never surfaces
|
|
144
|
+
* to the page as an error.
|
|
145
|
+
*/
|
|
146
|
+
class SearchUnsupported extends Error {}
|
|
147
|
+
|
|
148
|
+
function resolveOrmModel(modelName: string): any {
|
|
149
|
+
const injected = (globalThis as Record<string, any>)[modelName]
|
|
150
|
+
// The ORM injects Proxies that answer `undefined` for every property
|
|
151
|
+
// until their deferred load lands, so probe for a real query method
|
|
152
|
+
// rather than trusting the name to be bound.
|
|
153
|
+
if (injected && typeof injected.where === 'function') return injected
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
51
156
|
|
|
52
157
|
export default new Action({
|
|
53
158
|
name: 'Dashboard Model Show',
|
|
54
|
-
description: '
|
|
159
|
+
description: 'Queries a single model by URL slug with paging, sorting, search and column filters.',
|
|
55
160
|
method: 'GET',
|
|
56
161
|
apiResponse: true,
|
|
57
|
-
async handle(
|
|
58
|
-
const slug =
|
|
162
|
+
async handle(req: { getParam?: (name: string) => unknown, route?: { params?: { slug?: string } } }) {
|
|
163
|
+
const slug = String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? '')
|
|
59
164
|
const modelName = slugToPascal(slug)
|
|
60
165
|
const tableName = pluralize(pascalToSnake(modelName))
|
|
61
166
|
|
|
167
|
+
const params = queryParams()
|
|
168
|
+
const page = Math.max(1, Number.parseInt(params.get('page') || '1', 10) || 1)
|
|
169
|
+
const perPage = Math.min(MAX_PER_PAGE, Math.max(1, Number.parseInt(params.get('per_page') || String(DEFAULT_PER_PAGE), 10) || DEFAULT_PER_PAGE))
|
|
170
|
+
const requestedSort = (params.get('sort') || '').trim()
|
|
171
|
+
const dir: 'asc' | 'desc' = params.get('dir') === 'asc' ? 'asc' : 'desc'
|
|
172
|
+
const q = (params.get('q') || '').trim()
|
|
173
|
+
const filters = parseFilters(params.get('filters'))
|
|
174
|
+
|
|
62
175
|
const response: ResponseShape = {
|
|
63
176
|
modelName,
|
|
64
177
|
tableName,
|
|
65
178
|
rows: [],
|
|
66
179
|
columns: [],
|
|
67
180
|
displayColumns: [],
|
|
181
|
+
columnMeta: [],
|
|
182
|
+
searchable: [],
|
|
68
183
|
totalCount: 0,
|
|
184
|
+
page,
|
|
185
|
+
perPage,
|
|
186
|
+
lastPage: 1,
|
|
187
|
+
sort: '',
|
|
188
|
+
dir,
|
|
189
|
+
q,
|
|
190
|
+
filters,
|
|
69
191
|
source: 'orm',
|
|
192
|
+
writable: false,
|
|
70
193
|
error: null,
|
|
71
194
|
}
|
|
72
195
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
196
|
+
const Model = resolveOrmModel(modelName) ?? await loadModel(modelName)
|
|
197
|
+
const hasOrm = Boolean(Model) && !Model._isStub && typeof Model.where === 'function'
|
|
198
|
+
|
|
199
|
+
if (hasOrm) {
|
|
200
|
+
try {
|
|
201
|
+
// The column list has to come from a real row: the ORM does not
|
|
202
|
+
// expose a schema reflection API, and the filter/sort allowlist
|
|
203
|
+
// must be built from actual columns rather than user input.
|
|
204
|
+
const probe = await Model.orderByDesc('id').take(1).get() as Array<Record<string, unknown>>
|
|
205
|
+
const first = Array.isArray(probe) ? probe[0] : undefined
|
|
206
|
+
const shape = first && typeof first === 'object'
|
|
207
|
+
? ((first as { attributes?: Record<string, unknown> }).attributes ?? first)
|
|
208
|
+
: {}
|
|
209
|
+
response.columns = Object.keys(shape).filter(k => typeof k === 'string' && !k.startsWith('_'))
|
|
210
|
+
|
|
211
|
+
const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
|
|
212
|
+
const sort = isSafeColumn(requestedSort, allowed)
|
|
213
|
+
? requestedSort
|
|
214
|
+
: (allowed.has('id') ? 'id' : (response.columns[0] ?? ''))
|
|
215
|
+
response.sort = sort
|
|
216
|
+
|
|
217
|
+
// Text columns are decided from the probe row, before any query is
|
|
218
|
+
// built, so the search clause does not depend on response metadata
|
|
219
|
+
// that is only assembled after the rows come back.
|
|
220
|
+
const searchColumns = [...allowed].filter(c => typeof safeGet(first, c, null) === 'string')
|
|
221
|
+
|
|
222
|
+
const build = () => {
|
|
223
|
+
let chain = Model as any
|
|
224
|
+
for (const [column, value] of Object.entries(filters)) {
|
|
225
|
+
if (!isSafeColumn(column, allowed)) continue
|
|
226
|
+
chain = chain.where(column, value)
|
|
227
|
+
}
|
|
228
|
+
// Global search is a LIKE across every text column. The builder's
|
|
229
|
+
// `whereLike` covers one column and `orWhere` only takes equality
|
|
230
|
+
// pairs, so a multi-column OR-LIKE has no ORM expression; that
|
|
231
|
+
// case is served by the SQLite path below instead, which builds
|
|
232
|
+
// the OR with bound parameters. Writes stay enabled either way,
|
|
233
|
+
// because the model still exists.
|
|
234
|
+
if (q && searchColumns.length === 1)
|
|
235
|
+
chain = chain.whereLike(searchColumns[0], `%${q}%`)
|
|
236
|
+
return chain
|
|
87
237
|
}
|
|
88
238
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
239
|
+
// A multi-column search has no ORM expression, so hand that one
|
|
240
|
+
// query to the SQLite path — without recording an error, because it
|
|
241
|
+
// is a gap in the builder's vocabulary, not a failure.
|
|
242
|
+
if (q && searchColumns.length !== 1)
|
|
243
|
+
throw new SearchUnsupported()
|
|
244
|
+
|
|
245
|
+
response.totalCount = await build().count()
|
|
246
|
+
const rows = await build()
|
|
247
|
+
.orderBy(sort, dir)
|
|
248
|
+
.skip((page - 1) * perPage)
|
|
249
|
+
.take(perPage)
|
|
250
|
+
.get() as Array<Record<string, unknown>>
|
|
251
|
+
|
|
252
|
+
// Flatten to plain JSON-safe objects — proxy models and
|
|
253
|
+
// accessor-rich rows do not serialise cleanly across the wire.
|
|
254
|
+
response.rows = (Array.isArray(rows) ? rows : []).map((row) => {
|
|
92
255
|
const flat: Record<string, unknown> = {}
|
|
93
256
|
for (const col of response.columns)
|
|
94
257
|
flat[col] = safeGet(row, col, null)
|
|
95
258
|
return flat
|
|
96
259
|
})
|
|
97
260
|
}
|
|
98
|
-
|
|
261
|
+
catch (e) {
|
|
99
262
|
response.source = 'sqlite-fallback'
|
|
263
|
+
if (!(e instanceof SearchUnsupported))
|
|
264
|
+
response.error = e instanceof Error ? e.message : String(e)
|
|
100
265
|
}
|
|
101
266
|
}
|
|
102
|
-
|
|
267
|
+
else {
|
|
103
268
|
response.source = 'sqlite-fallback'
|
|
104
|
-
response.error = e instanceof Error ? e.message : String(e)
|
|
105
269
|
}
|
|
106
270
|
|
|
107
|
-
|
|
108
|
-
if (response.source === 'sqlite-fallback' && response.rows.length === 0) {
|
|
271
|
+
if (response.source === 'sqlite-fallback') {
|
|
109
272
|
try {
|
|
110
273
|
const { Database } = await import('bun:sqlite')
|
|
111
274
|
const db = new Database('database/stacks.sqlite')
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
response.
|
|
117
|
-
|
|
118
|
-
|
|
275
|
+
try {
|
|
276
|
+
const tableInfo = db.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string, type: string }>
|
|
277
|
+
response.columns = tableInfo.map(c => c.name)
|
|
278
|
+
|
|
279
|
+
if (response.columns.length > 0) {
|
|
280
|
+
const allowed = new Set(response.columns.filter(c => !HIDDEN_COLUMNS.has(c)))
|
|
281
|
+
const sort = isSafeColumn(requestedSort, allowed)
|
|
282
|
+
? requestedSort
|
|
283
|
+
: (allowed.has('id') ? 'id' : response.columns[0])
|
|
284
|
+
response.sort = sort
|
|
285
|
+
|
|
286
|
+
// Every value is bound; only identifiers are interpolated, and
|
|
287
|
+
// those come from the allowlist built out of PRAGMA above.
|
|
288
|
+
const wheres: string[] = []
|
|
289
|
+
const values: unknown[] = []
|
|
290
|
+
for (const [column, value] of Object.entries(filters)) {
|
|
291
|
+
if (!isSafeColumn(column, allowed)) continue
|
|
292
|
+
wheres.push(`${column} = ?`)
|
|
293
|
+
values.push(value)
|
|
294
|
+
}
|
|
295
|
+
if (q) {
|
|
296
|
+
const textColumns = tableInfo
|
|
297
|
+
.filter(c => allowed.has(c.name) && /char|text|clob/i.test(c.type || ''))
|
|
298
|
+
.map(c => c.name)
|
|
299
|
+
if (textColumns.length > 0) {
|
|
300
|
+
wheres.push(`(${textColumns.map(c => `${c} LIKE ?`).join(' OR ')})`)
|
|
301
|
+
for (const _ of textColumns) values.push(`%${q}%`)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const whereSql = wheres.length > 0 ? ` WHERE ${wheres.join(' AND ')}` : ''
|
|
305
|
+
|
|
306
|
+
const countRow = db.query(`SELECT COUNT(*) as count FROM ${tableName}${whereSql}`).get(...values as any[]) as { count?: number } | null
|
|
307
|
+
response.totalCount = countRow?.count ?? 0
|
|
308
|
+
response.rows = db
|
|
309
|
+
.query(`SELECT * FROM ${tableName}${whereSql} ORDER BY ${sort} ${dir.toUpperCase()} LIMIT ? OFFSET ?`)
|
|
310
|
+
.all(...values as any[], perPage, (page - 1) * perPage) as Array<Record<string, unknown>>
|
|
311
|
+
response.error = null
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
db.close()
|
|
119
316
|
}
|
|
120
|
-
db.close()
|
|
121
317
|
}
|
|
122
318
|
catch (e) {
|
|
123
319
|
response.error = e instanceof Error ? e.message : String(e)
|
|
124
320
|
}
|
|
125
321
|
}
|
|
126
322
|
|
|
323
|
+
// Writability follows the existence of a model, not which engine
|
|
324
|
+
// answered this particular query: a multi-column search is served by
|
|
325
|
+
// SQLite even for a model that is perfectly writable.
|
|
326
|
+
response.writable = hasOrm
|
|
127
327
|
response.displayColumns = response.columns.filter(col => !HIDDEN_COLUMNS.has(col) && !col.startsWith('_'))
|
|
328
|
+
response.columnMeta = response.displayColumns.map(name => ({
|
|
329
|
+
name,
|
|
330
|
+
label: humanize(name),
|
|
331
|
+
type: typeForColumn(name, response.rows.map(r => r[name])),
|
|
332
|
+
}))
|
|
333
|
+
response.searchable = response.columnMeta.filter(c => c.type === 'text').map(c => c.name)
|
|
334
|
+
response.lastPage = Math.max(1, Math.ceil(response.totalCount / perPage))
|
|
128
335
|
|
|
129
336
|
return response
|
|
130
337
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Action } from '@stacksjs/actions'
|
|
2
|
+
import { request } from '@stacksjs/router'
|
|
3
|
+
import { parseRowId, PROTECTED_COLUMNS, resolveWritableModel } from './model-write'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `PATCH /api/dashboard/models/{slug}/{id}`.
|
|
7
|
+
*
|
|
8
|
+
* Edits one row from the dashboard's generic model browser. The page's
|
|
9
|
+
* edit control used to be decorative — a button with no handler — which is
|
|
10
|
+
* worse than having none, because the row reads as manageable and silently
|
|
11
|
+
* is not.
|
|
12
|
+
*/
|
|
13
|
+
export default new Action({
|
|
14
|
+
name: 'Dashboard Model Update',
|
|
15
|
+
description: 'Updates one row of a model from the dashboard model browser.',
|
|
16
|
+
method: 'PATCH',
|
|
17
|
+
apiResponse: true,
|
|
18
|
+
async handle(req: {
|
|
19
|
+
getParam?: (name: string) => unknown
|
|
20
|
+
all?: () => Record<string, unknown>
|
|
21
|
+
route?: { params?: { slug?: string, id?: string } }
|
|
22
|
+
}) {
|
|
23
|
+
const resolved = await resolveWritableModel(String(req?.getParam?.('slug') ?? req?.route?.params?.slug ?? ''))
|
|
24
|
+
if ('error' in resolved)
|
|
25
|
+
return { ok: false, error: resolved.error }
|
|
26
|
+
|
|
27
|
+
const id = parseRowId(req?.getParam?.('id') ?? req?.route?.params?.id)
|
|
28
|
+
if (id === null)
|
|
29
|
+
return { ok: false, error: 'A numeric row id is required.' }
|
|
30
|
+
|
|
31
|
+
// Edits arrive nested under `fields`. The router's input bag merges the
|
|
32
|
+
// body with route params and query, so a flat payload would silently
|
|
33
|
+
// pick up the `slug` and `id` from the URL and try to write them as
|
|
34
|
+
// columns — and `slug` is a real column on plenty of models, so it
|
|
35
|
+
// cannot simply be blocklisted. One level of nesting keeps the two
|
|
36
|
+
// namespaces apart.
|
|
37
|
+
const input = (req.all?.() ?? (request as any).all?.() ?? {}) as Record<string, unknown>
|
|
38
|
+
const raw = input.fields
|
|
39
|
+
const body = (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw as Record<string, unknown> : {}
|
|
40
|
+
const changes: Record<string, unknown> = {}
|
|
41
|
+
for (const [key, value] of Object.entries(body)) {
|
|
42
|
+
if (!/^\w+$/.test(key) || PROTECTED_COLUMNS.has(key)) continue
|
|
43
|
+
changes[key] = value
|
|
44
|
+
}
|
|
45
|
+
if (Object.keys(changes).length === 0)
|
|
46
|
+
return { ok: false, error: 'No writable fields in the request body.' }
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const row = await resolved.Model.find(id)
|
|
50
|
+
if (!row)
|
|
51
|
+
return { ok: false, error: `${resolved.modelName} ${id} not found.` }
|
|
52
|
+
await row.update(changes)
|
|
53
|
+
return { ok: true, id, changed: Object.keys(changes) }
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared plumbing for the dashboard model browser's write endpoints.
|
|
3
|
+
*
|
|
4
|
+
* Only the ORM path is writable. A table reachable solely through the
|
|
5
|
+
* read-side SQLite fallback has no model, so it has no fillable list, no
|
|
6
|
+
* casts and no observers; writing to it from a generic admin screen would
|
|
7
|
+
* bypass every guarantee the ORM makes. Those rows stay read-only.
|
|
8
|
+
*/
|
|
9
|
+
import { loadModel } from '../../../../resources/functions/dashboard/data'
|
|
10
|
+
|
|
11
|
+
/** Never writable from a generic admin table, whatever the model declares. */
|
|
12
|
+
export const PROTECTED_COLUMNS: ReadonlySet<string> = new Set([
|
|
13
|
+
'id',
|
|
14
|
+
'uuid',
|
|
15
|
+
'created_at',
|
|
16
|
+
'updated_at',
|
|
17
|
+
'password',
|
|
18
|
+
'remember_token',
|
|
19
|
+
'api_token',
|
|
20
|
+
'access_token',
|
|
21
|
+
'refresh_token',
|
|
22
|
+
'secret',
|
|
23
|
+
'two_factor_secret',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
export function slugToPascal(str: string): string {
|
|
27
|
+
return str.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedModel {
|
|
31
|
+
Model: any
|
|
32
|
+
modelName: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a URL slug to a writable model, preferring the global the ORM
|
|
37
|
+
* injects (the same object the rest of the dashboard queries, so scopes,
|
|
38
|
+
* casts and observers all apply) over the path-map lookup.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveWritableModel(slug: string): Promise<ResolvedModel | { error: string }> {
|
|
41
|
+
const modelName = slugToPascal(slug)
|
|
42
|
+
const injected = (globalThis as Record<string, any>)[modelName]
|
|
43
|
+
const Model = (injected && typeof injected.where === 'function') ? injected : await loadModel(modelName)
|
|
44
|
+
if (!Model || Model._isStub || typeof Model.find !== 'function')
|
|
45
|
+
return { error: `No ORM model named ${modelName}. Rows from tables without a model are read-only.` }
|
|
46
|
+
return { Model, modelName }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseRowId(raw: unknown): number | null {
|
|
50
|
+
const id = Number.parseInt(String(raw ?? ''), 10)
|
|
51
|
+
return Number.isFinite(id) && id > 0 ? id : null
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard API client.
|
|
3
|
+
*
|
|
4
|
+
* Every mutating dashboard endpoint sits behind a stateless double-submit
|
|
5
|
+
* CSRF cookie: the server seeds a readable `X-CSRF-Token` cookie on safe
|
|
6
|
+
* requests, and a POST/PATCH/DELETE has to echo it back in a header. Pages
|
|
7
|
+
* were each re-implementing that cookie read inline, which put bare
|
|
8
|
+
* `document.*` access into template scripts and meant a change to the
|
|
9
|
+
* scheme had to be made in every page that writes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ApiRequestOptions {
|
|
13
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
|
14
|
+
/** JSON request body. Omitted entirely for GET. */
|
|
15
|
+
body?: unknown
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Read the CSRF cookie the server seeds on safe requests. */
|
|
20
|
+
function csrfToken(): string | null {
|
|
21
|
+
if (typeof document === 'undefined')
|
|
22
|
+
return null
|
|
23
|
+
const match = document.cookie.match(/(?:^|;\s*)X-CSRF-Token=([^;]*)/)
|
|
24
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Call a dashboard API endpoint and return its parsed JSON.
|
|
29
|
+
*
|
|
30
|
+
* Throws on a non-2xx response, using the server's `message` / `error`
|
|
31
|
+
* field when there is one so callers can surface something better than a
|
|
32
|
+
* bare status code.
|
|
33
|
+
*/
|
|
34
|
+
export async function dashboardApi<T = unknown>(path: string, options: ApiRequestOptions = {}): Promise<T> {
|
|
35
|
+
const method = options.method ?? 'GET'
|
|
36
|
+
const headers: Record<string, string> = { accept: 'application/json' }
|
|
37
|
+
|
|
38
|
+
if (options.body !== undefined)
|
|
39
|
+
headers['content-type'] = 'application/json'
|
|
40
|
+
|
|
41
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
42
|
+
const token = csrfToken()
|
|
43
|
+
if (token)
|
|
44
|
+
headers['X-CSRF-Token'] = token
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const res = await fetch(path, {
|
|
48
|
+
method,
|
|
49
|
+
headers,
|
|
50
|
+
signal: options.signal,
|
|
51
|
+
...(options.body !== undefined && { body: JSON.stringify(options.body) }),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const text = await res.text()
|
|
55
|
+
let payload: any = null
|
|
56
|
+
try {
|
|
57
|
+
payload = text ? JSON.parse(text) : null
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
payload = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const detail = payload?.message || payload?.error || `HTTP ${res.status}`
|
|
65
|
+
throw new Error(String(detail))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return payload as T
|
|
69
|
+
}
|
package/ide/vscode/package.json
CHANGED
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/defaults",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.70.
|
|
5
|
+
"version": "0.70.228",
|
|
6
6
|
"description": "The complete managed Stacks application scaffold, including runtime defaults, AI guidance, editor metadata, and npm-backed project support files.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"license": "MIT",
|
package/routes/dashboard-api.ts
CHANGED
|
@@ -203,11 +203,18 @@ route.group({ prefix: '/api/dashboard', apiResponse: true }, () => {
|
|
|
203
203
|
// `views/dashboard/models/index.stx` page (stacksjs/stacks#1838).
|
|
204
204
|
guard(route.get('/models', 'Actions/Dashboard/Models/ModelsIndexAction'))
|
|
205
205
|
|
|
206
|
-
// Per-model row
|
|
207
|
-
//
|
|
208
|
-
// first, raw SQLite fallback if no model file
|
|
206
|
+
// Per-model row query for the dynamic `views/dashboard/models/[model].stx`
|
|
207
|
+
// page: paging, sorting, search and column filters, all resolved
|
|
208
|
+
// server-side. ORM path first, raw SQLite fallback if no model file
|
|
209
|
+
// matches the slug.
|
|
209
210
|
guard(route.get('/models/{slug}', 'Actions/Dashboard/Models/ModelShowAction'))
|
|
210
211
|
|
|
212
|
+
// Row writes from the same page. Guarded like every other mutating
|
|
213
|
+
// dashboard endpoint; models with no ORM file stay read-only, which the
|
|
214
|
+
// actions enforce rather than the route.
|
|
215
|
+
guard(route.patch('/models/{slug}/{id}', 'Actions/Dashboard/Models/ModelUpdateAction'))
|
|
216
|
+
guard(route.delete('/models/{slug}/{id}', 'Actions/Dashboard/Models/ModelDestroyAction'))
|
|
217
|
+
|
|
211
218
|
// Markdown blog admin — the write side of the BunPress blog that /blog
|
|
212
219
|
// renders from `content/blog/*.md` (storage/framework/core/actions/src/blog.ts).
|
|
213
220
|
// Backs `views/dashboard/content/blog/index.stx`.
|