webspresso 0.0.97 → 0.0.99
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/.agents/ADMIN.md +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +4 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
|
@@ -9,6 +9,7 @@ const { sanitizeForOutput } = require('../../core/orm/utils');
|
|
|
9
9
|
const { checkAdminExists, setupAdmin, login, logout, requireAuth } = require('./auth');
|
|
10
10
|
const { isRichTextEmpty } = require('./lib/is-rich-text-empty');
|
|
11
11
|
const { sanitizeRichHtml } = require('./lib/sanitize-rich-html');
|
|
12
|
+
const { LoginRateLimiter } = require('./lib/login-rate-limiter');
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Create API route handlers
|
|
@@ -19,19 +20,23 @@ const { sanitizeRichHtml } = require('./lib/sanitize-rich-html');
|
|
|
19
20
|
* @param {Function} options.hashPassword - Bcrypt hash function
|
|
20
21
|
* @param {Function} options.comparePassword - Bcrypt compare function
|
|
21
22
|
* @param {boolean} [options.richTextSanitize=true] - Sanitize rich-text HTML before create/update
|
|
23
|
+
* @param {boolean|Object} [options.loginRateLimit] - Login rate limiting options
|
|
22
24
|
* @returns {Object} Route handlers
|
|
23
25
|
*/
|
|
24
26
|
function createApiHandlers(options) {
|
|
25
27
|
const {
|
|
26
28
|
path,
|
|
27
29
|
db,
|
|
30
|
+
registry,
|
|
28
31
|
AdminUser,
|
|
29
32
|
hashPassword,
|
|
30
33
|
comparePassword,
|
|
31
34
|
richTextSanitize = true,
|
|
35
|
+
loginRateLimit,
|
|
32
36
|
} = options;
|
|
33
37
|
const adminPath = path || '/_admin';
|
|
34
38
|
const apiPath = `${adminPath}/api`;
|
|
39
|
+
const rateLimiter = options.rateLimiter || new LoginRateLimiter(loginRateLimit);
|
|
35
40
|
|
|
36
41
|
/** Strip XSS vectors from rich-text columns present on body; nullable columns normalize empty → null */
|
|
37
42
|
function mutateRichTextFieldsInBody(body, model) {
|
|
@@ -113,11 +118,22 @@ function createApiHandlers(options) {
|
|
|
113
118
|
*/
|
|
114
119
|
async function loginHandler(req, res) {
|
|
115
120
|
try {
|
|
121
|
+
const blockStatus = rateLimiter.isBlocked(req);
|
|
122
|
+
if (blockStatus.blocked) {
|
|
123
|
+
if (typeof res.setHeader === 'function') {
|
|
124
|
+
res.setHeader('Retry-After', String(blockStatus.retryAfter));
|
|
125
|
+
}
|
|
126
|
+
return res.status(429).json({
|
|
127
|
+
error: blockStatus.message,
|
|
128
|
+
retryAfter: blockStatus.retryAfter,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
116
132
|
if (!AdminUserRepo || !comparePassword) {
|
|
117
133
|
return res.status(500).json({ error: 'Admin user system not initialized' });
|
|
118
134
|
}
|
|
119
135
|
|
|
120
|
-
const { email, password } = req.body;
|
|
136
|
+
const { email, password } = req.body || {};
|
|
121
137
|
|
|
122
138
|
if (!email || !password) {
|
|
123
139
|
return res.status(400).json({ error: 'Email and password are required' });
|
|
@@ -126,9 +142,12 @@ function createApiHandlers(options) {
|
|
|
126
142
|
const user = await login(AdminUserRepo, email, password, comparePassword);
|
|
127
143
|
|
|
128
144
|
if (!user) {
|
|
145
|
+
rateLimiter.recordFailure(req);
|
|
129
146
|
return res.status(401).json({ error: 'Invalid credentials' });
|
|
130
147
|
}
|
|
131
148
|
|
|
149
|
+
rateLimiter.recordSuccess(req);
|
|
150
|
+
|
|
132
151
|
// Set session
|
|
133
152
|
req.session.adminUser = user;
|
|
134
153
|
|
|
@@ -258,6 +277,11 @@ function createApiHandlers(options) {
|
|
|
258
277
|
primaryKey: model.primaryKey,
|
|
259
278
|
columns: Array.from(model.columns.keys()),
|
|
260
279
|
relations: Object.keys(model.relations),
|
|
280
|
+
group: model.admin.group || null,
|
|
281
|
+
readOnly: model.admin.readOnly === true,
|
|
282
|
+
permissions: model.admin.permissions || null,
|
|
283
|
+
displayField: model.admin.displayField || null,
|
|
284
|
+
defaultSort: model.admin.defaultSort || null,
|
|
261
285
|
});
|
|
262
286
|
}
|
|
263
287
|
}
|
|
@@ -311,6 +335,7 @@ function isColumnSortable(name, meta, model) {
|
|
|
311
335
|
const columns = [];
|
|
312
336
|
for (const [name, meta] of model.columns) {
|
|
313
337
|
const isHidden = hiddenSet.has(name);
|
|
338
|
+
const colAdminConfig = model.admin.columns?.[name] || null;
|
|
314
339
|
columns.push({
|
|
315
340
|
name,
|
|
316
341
|
type: meta.type,
|
|
@@ -327,6 +352,7 @@ function isColumnSortable(name, meta, model) {
|
|
|
327
352
|
ui: meta.ui ? { ...meta.ui, hidden: isHidden || meta.ui.hidden } : (isHidden ? { hidden: true } : null),
|
|
328
353
|
hidden: isHidden, // Excluded from list display and API responses
|
|
329
354
|
sortable: isColumnSortable(name, meta, model),
|
|
355
|
+
badge: colAdminConfig?.badge || meta.badge || model.admin.customFields?.[name]?.badge || null,
|
|
330
356
|
});
|
|
331
357
|
}
|
|
332
358
|
|
|
@@ -340,6 +366,48 @@ function isColumnSortable(name, meta, model) {
|
|
|
340
366
|
relations: Object.keys(model.relations),
|
|
341
367
|
queries: Object.keys(model.admin.queries || {}),
|
|
342
368
|
softDelete: !!model.scopes?.softDelete,
|
|
369
|
+
listColumns: model.admin.listColumns || null,
|
|
370
|
+
searchFields: model.admin.searchFields || null,
|
|
371
|
+
displayField: model.admin.displayField || null,
|
|
372
|
+
readOnly: model.admin.readOnly === true,
|
|
373
|
+
permissions: model.admin.permissions || null,
|
|
374
|
+
defaultSort: model.admin.defaultSort || null,
|
|
375
|
+
group: model.admin.group || null,
|
|
376
|
+
columnsConfig: model.admin.columns || null,
|
|
377
|
+
relationsConfig: model.admin.relations || null,
|
|
378
|
+
actions: [
|
|
379
|
+
...(registry && typeof registry.getActionsForModel === 'function' ? registry.getActionsForModel(model.name) : []),
|
|
380
|
+
...(Array.isArray(model.admin.actions) ? model.admin.actions : []),
|
|
381
|
+
].reduce((acc, a) => {
|
|
382
|
+
if (!acc.some(x => x.id === a.id)) {
|
|
383
|
+
acc.push({
|
|
384
|
+
id: a.id,
|
|
385
|
+
label: a.label,
|
|
386
|
+
icon: a.icon || null,
|
|
387
|
+
color: a.color || 'gray',
|
|
388
|
+
confirm: a.confirm === true,
|
|
389
|
+
confirmMessage: a.confirmMessage || null,
|
|
390
|
+
showWhen: typeof a.showWhen === 'object' && a.showWhen !== null ? a.showWhen : null,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return acc;
|
|
394
|
+
}, []),
|
|
395
|
+
bulkActions: [
|
|
396
|
+
...(registry && typeof registry.getBulkActionsForModel === 'function' ? registry.getBulkActionsForModel(model.name) : []),
|
|
397
|
+
...(Array.isArray(model.admin.bulkActions) ? model.admin.bulkActions : []),
|
|
398
|
+
].reduce((acc, a) => {
|
|
399
|
+
if (!acc.some(x => x.id === a.id)) {
|
|
400
|
+
acc.push({
|
|
401
|
+
id: a.id,
|
|
402
|
+
label: a.label,
|
|
403
|
+
icon: a.icon || null,
|
|
404
|
+
color: a.color || 'gray',
|
|
405
|
+
confirm: a.confirm !== false,
|
|
406
|
+
confirmMessage: a.confirmMessage || null,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return acc;
|
|
410
|
+
}, []),
|
|
343
411
|
});
|
|
344
412
|
} catch (error) {
|
|
345
413
|
res.status(500).json({ error: error.message });
|
|
@@ -477,12 +545,17 @@ function isColumnSortable(name, meta, model) {
|
|
|
477
545
|
}
|
|
478
546
|
}
|
|
479
547
|
|
|
480
|
-
//
|
|
548
|
+
// Search support (respects model.admin.searchFields if configured, falls back to string columns)
|
|
481
549
|
if (req.query.search) {
|
|
482
550
|
const searchTerm = `%${req.query.search}%`;
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
.
|
|
551
|
+
let stringColumns;
|
|
552
|
+
if (Array.isArray(model.admin.searchFields) && model.admin.searchFields.length > 0) {
|
|
553
|
+
stringColumns = model.admin.searchFields.filter((colName) => model.columns.has(colName));
|
|
554
|
+
} else {
|
|
555
|
+
stringColumns = Array.from(model.columns.entries())
|
|
556
|
+
.filter(([_, meta]) => meta.type === 'string' || meta.type === 'text' || meta.type === 'file')
|
|
557
|
+
.map(([name]) => name);
|
|
558
|
+
}
|
|
486
559
|
|
|
487
560
|
if (stringColumns.length > 0) {
|
|
488
561
|
query = query.where(function() {
|
|
@@ -515,16 +588,20 @@ function isColumnSortable(name, meta, model) {
|
|
|
515
588
|
// Apply sorting
|
|
516
589
|
const primaryKey = model.primaryKey || 'id';
|
|
517
590
|
const sortParam = req.query.sort;
|
|
518
|
-
const orderParam = (req.query.order || '
|
|
519
|
-
const sortOrder = orderParam === 'asc' ? 'asc' : 'desc';
|
|
591
|
+
const orderParam = (req.query.order || req.query.direction || '').toLowerCase();
|
|
520
592
|
|
|
521
593
|
if (sortParam && model.columns.has(sortParam)) {
|
|
594
|
+
const sortOrder = orderParam === 'asc' ? 'asc' : 'desc';
|
|
522
595
|
const colMeta = model.columns.get(sortParam);
|
|
523
596
|
if (isColumnSortable(sortParam, colMeta, model)) {
|
|
524
597
|
query = query.orderBy(sortParam, sortOrder);
|
|
525
598
|
} else {
|
|
526
599
|
query = query.orderBy(primaryKey, 'desc');
|
|
527
600
|
}
|
|
601
|
+
} else if (model.admin.defaultSort && model.admin.defaultSort.column && model.columns.has(model.admin.defaultSort.column)) {
|
|
602
|
+
const defCol = model.admin.defaultSort.column;
|
|
603
|
+
const defOrder = (model.admin.defaultSort.order || model.admin.defaultSort.direction || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
604
|
+
query = query.orderBy(defCol, defOrder);
|
|
528
605
|
} else {
|
|
529
606
|
query = query.orderBy(primaryKey, 'desc');
|
|
530
607
|
}
|
|
@@ -565,9 +642,14 @@ function isColumnSortable(name, meta, model) {
|
|
|
565
642
|
return res.status(404).json({ error: 'Record not found' });
|
|
566
643
|
}
|
|
567
644
|
|
|
645
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
646
|
+
await registry.runHooks('onRecordView', { model: model.name, id, record, req, user: req.session?.adminUser, db });
|
|
647
|
+
}
|
|
648
|
+
|
|
568
649
|
res.json({ data: sanitizeForOutput(record, model) });
|
|
569
650
|
} catch (error) {
|
|
570
|
-
|
|
651
|
+
const statusCode = error.statusCode || error.status || 500;
|
|
652
|
+
res.status(statusCode).json({ error: error.message });
|
|
571
653
|
}
|
|
572
654
|
}
|
|
573
655
|
|
|
@@ -609,8 +691,19 @@ function isColumnSortable(name, meta, model) {
|
|
|
609
691
|
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
610
692
|
}
|
|
611
693
|
|
|
694
|
+
if (model.admin.readOnly === true) {
|
|
695
|
+
return res.status(403).json({ error: `Model "${model.name}" is read-only` });
|
|
696
|
+
}
|
|
697
|
+
if (model.admin.permissions?.create === false) {
|
|
698
|
+
return res.status(403).json({ error: `Creation is disabled for model "${model.name}"` });
|
|
699
|
+
}
|
|
700
|
+
|
|
612
701
|
mutateRichTextFieldsInBody(req.body, model);
|
|
613
702
|
|
|
703
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
704
|
+
await registry.runHooks('beforeCreate', { model: model.name, data: req.body, req, user: req.session?.adminUser, db });
|
|
705
|
+
}
|
|
706
|
+
|
|
614
707
|
// Validate rich-text fields
|
|
615
708
|
for (const [colName, colMeta] of model.columns) {
|
|
616
709
|
if (model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
|
|
@@ -627,10 +720,15 @@ function isColumnSortable(name, meta, model) {
|
|
|
627
720
|
const repo = db.getRepository(model.name);
|
|
628
721
|
const record = await repo.create(req.body);
|
|
629
722
|
|
|
723
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
724
|
+
await registry.runHooks('afterCreate', { model: model.name, record, data: req.body, req, user: req.session?.adminUser, db });
|
|
725
|
+
}
|
|
726
|
+
|
|
630
727
|
res.status(201).json({ data: sanitizeForOutput(record, model) });
|
|
631
728
|
} catch (error) {
|
|
632
729
|
const fields = extractValidationErrors(error);
|
|
633
|
-
|
|
730
|
+
const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : error.name === 'NotFoundError' ? 404 : 400);
|
|
731
|
+
res.status(statusCode).json({ error: error.message, ...(fields ? { fields } : {}) });
|
|
634
732
|
}
|
|
635
733
|
}
|
|
636
734
|
|
|
@@ -646,8 +744,19 @@ function isColumnSortable(name, meta, model) {
|
|
|
646
744
|
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
647
745
|
}
|
|
648
746
|
|
|
747
|
+
if (model.admin.readOnly === true) {
|
|
748
|
+
return res.status(403).json({ error: `Model "${model.name}" is read-only` });
|
|
749
|
+
}
|
|
750
|
+
if (model.admin.permissions?.edit === false) {
|
|
751
|
+
return res.status(403).json({ error: `Editing is disabled for model "${model.name}"` });
|
|
752
|
+
}
|
|
753
|
+
|
|
649
754
|
mutateRichTextFieldsInBody(req.body, model);
|
|
650
755
|
|
|
756
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
757
|
+
await registry.runHooks('beforeUpdate', { model: model.name, id, data: req.body, req, user: req.session?.adminUser, db });
|
|
758
|
+
}
|
|
759
|
+
|
|
651
760
|
// Validate rich-text fields (only if field is being updated)
|
|
652
761
|
for (const [colName, colMeta] of model.columns) {
|
|
653
762
|
if (colName in req.body && model.admin.customFields?.[colName]?.type === 'rich-text' && !colMeta.nullable) {
|
|
@@ -668,10 +777,15 @@ function isColumnSortable(name, meta, model) {
|
|
|
668
777
|
return res.status(404).json({ error: 'Record not found' });
|
|
669
778
|
}
|
|
670
779
|
|
|
780
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
781
|
+
await registry.runHooks('afterUpdate', { model: model.name, id, record, data: req.body, req, user: req.session?.adminUser, db });
|
|
782
|
+
}
|
|
783
|
+
|
|
671
784
|
res.json({ data: sanitizeForOutput(record, model) });
|
|
672
785
|
} catch (error) {
|
|
673
786
|
const fields = extractValidationErrors(error);
|
|
674
|
-
|
|
787
|
+
const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : error.name === 'NotFoundError' ? 404 : 400);
|
|
788
|
+
res.status(statusCode).json({ error: error.message, ...(fields ? { fields } : {}) });
|
|
675
789
|
}
|
|
676
790
|
}
|
|
677
791
|
|
|
@@ -687,6 +801,17 @@ function isColumnSortable(name, meta, model) {
|
|
|
687
801
|
return res.status(404).json({ error: 'Model not found or not enabled' });
|
|
688
802
|
}
|
|
689
803
|
|
|
804
|
+
if (model.admin.readOnly === true) {
|
|
805
|
+
return res.status(403).json({ error: `Model "${model.name}" is read-only` });
|
|
806
|
+
}
|
|
807
|
+
if (model.admin.permissions?.delete === false) {
|
|
808
|
+
return res.status(403).json({ error: `Deletion is disabled for model "${model.name}"` });
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
812
|
+
await registry.runHooks('beforeDelete', { model: model.name, id, req, user: req.session?.adminUser, db });
|
|
813
|
+
}
|
|
814
|
+
|
|
690
815
|
const repo = db.getRepository(model.name);
|
|
691
816
|
const deleted = await repo.delete(id);
|
|
692
817
|
|
|
@@ -694,9 +819,14 @@ function isColumnSortable(name, meta, model) {
|
|
|
694
819
|
return res.status(404).json({ error: 'Record not found' });
|
|
695
820
|
}
|
|
696
821
|
|
|
822
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
823
|
+
await registry.runHooks('afterDelete', { model: model.name, id, req, user: req.session?.adminUser, db });
|
|
824
|
+
}
|
|
825
|
+
|
|
697
826
|
res.json({ success: true });
|
|
698
827
|
} catch (error) {
|
|
699
|
-
|
|
828
|
+
const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : 500);
|
|
829
|
+
res.status(statusCode).json({ error: error.message });
|
|
700
830
|
}
|
|
701
831
|
}
|
|
702
832
|
|
|
@@ -716,6 +846,10 @@ function isColumnSortable(name, meta, model) {
|
|
|
716
846
|
return res.status(400).json({ error: 'Model does not support soft delete' });
|
|
717
847
|
}
|
|
718
848
|
|
|
849
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
850
|
+
await registry.runHooks('beforeRestore', { model: model.name, id, req, user: req.session?.adminUser, db });
|
|
851
|
+
}
|
|
852
|
+
|
|
719
853
|
const repo = db.getRepository(model.name);
|
|
720
854
|
const record = await repo.restore(id);
|
|
721
855
|
|
|
@@ -723,14 +857,19 @@ function isColumnSortable(name, meta, model) {
|
|
|
723
857
|
return res.status(404).json({ error: 'Record not found in trash' });
|
|
724
858
|
}
|
|
725
859
|
|
|
860
|
+
if (registry && typeof registry.runHooks === 'function') {
|
|
861
|
+
await registry.runHooks('afterRestore', { model: model.name, id, record, req, user: req.session?.adminUser, db });
|
|
862
|
+
}
|
|
863
|
+
|
|
726
864
|
res.json({ success: true, data: sanitizeForOutput(record, model) });
|
|
727
865
|
} catch (error) {
|
|
728
|
-
|
|
866
|
+
const statusCode = error.statusCode || error.status || (error.name === 'ForbiddenError' ? 403 : 500);
|
|
867
|
+
res.status(statusCode).json({ error: error.message });
|
|
729
868
|
}
|
|
730
869
|
}
|
|
731
870
|
|
|
732
871
|
/**
|
|
733
|
-
* Get relation data
|
|
872
|
+
* Get relation data (with search, pagination, projection, and selected item inclusion)
|
|
734
873
|
*/
|
|
735
874
|
async function relationHandler(req, res) {
|
|
736
875
|
try {
|
|
@@ -746,13 +885,133 @@ function isColumnSortable(name, meta, model) {
|
|
|
746
885
|
return res.status(404).json({ error: 'Relation not found' });
|
|
747
886
|
}
|
|
748
887
|
|
|
749
|
-
const
|
|
888
|
+
const rawRelatedModel = typeof relation.model === 'function' ? relation.model() : relation.model;
|
|
889
|
+
const relatedModel = typeof rawRelatedModel === 'string' ? getModelFromDb(rawRelatedModel) : rawRelatedModel;
|
|
890
|
+
if (!relatedModel) {
|
|
891
|
+
return res.status(404).json({ error: 'Related model not found' });
|
|
892
|
+
}
|
|
750
893
|
const relatedRepo = db.getRepository(relatedModel.name);
|
|
751
894
|
|
|
752
|
-
|
|
753
|
-
|
|
895
|
+
const relationConfig = model.admin?.relations?.[relationName] || {};
|
|
896
|
+
let displayField = relationConfig.displayField || relatedModel.admin?.displayField || null;
|
|
897
|
+
const primaryKey = relatedModel.primaryKey || 'id';
|
|
898
|
+
const searchFields = relationConfig.searchFields || relatedModel.admin?.searchFields || null;
|
|
754
899
|
|
|
755
|
-
|
|
900
|
+
if (!displayField && relatedModel.columns) {
|
|
901
|
+
const candidate = ['name', 'title', 'label', 'username', 'email', 'slug'].find(c => relatedModel.columns.has(c));
|
|
902
|
+
if (candidate) {
|
|
903
|
+
displayField = candidate;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const search = (req.query.search || req.query.q || '').trim();
|
|
908
|
+
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
|
909
|
+
const defaultLimit = typeof relationConfig.limit === 'number' && relationConfig.limit > 0 ? relationConfig.limit : 50;
|
|
910
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit || req.query.perPage, 10) || defaultLimit));
|
|
911
|
+
const isAll = req.query.all === 'true' || req.query.all === true;
|
|
912
|
+
const selectedId = req.query.selected || req.query.includeId || null;
|
|
913
|
+
|
|
914
|
+
let selectColumns = null;
|
|
915
|
+
if (displayField && relatedModel.columns) {
|
|
916
|
+
const cols = new Set([primaryKey]);
|
|
917
|
+
if (relatedModel.columns.has(displayField)) {
|
|
918
|
+
cols.add(displayField);
|
|
919
|
+
}
|
|
920
|
+
if (Array.isArray(searchFields)) {
|
|
921
|
+
for (const sf of searchFields) {
|
|
922
|
+
if (relatedModel.columns.has(sf)) cols.add(sf);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
selectColumns = Array.from(cols);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
let query = relatedRepo.query();
|
|
929
|
+
let countQuery = relatedRepo.query();
|
|
930
|
+
|
|
931
|
+
// Apply search filter if provided
|
|
932
|
+
if (search && relatedModel.columns) {
|
|
933
|
+
const searchTerm = `%${search}%`;
|
|
934
|
+
let targetSearchCols = [];
|
|
935
|
+
if (Array.isArray(searchFields) && searchFields.length > 0) {
|
|
936
|
+
targetSearchCols = searchFields.filter(col => relatedModel.columns.has(col));
|
|
937
|
+
} else if (displayField && relatedModel.columns.has(displayField)) {
|
|
938
|
+
targetSearchCols = [displayField];
|
|
939
|
+
} else {
|
|
940
|
+
targetSearchCols = Array.from(relatedModel.columns.entries())
|
|
941
|
+
.filter(([_, meta]) => meta.type === 'string' || meta.type === 'text')
|
|
942
|
+
.map(([name]) => name);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (targetSearchCols.length > 0) {
|
|
946
|
+
const filterFn = function() {
|
|
947
|
+
for (let i = 0; i < targetSearchCols.length; i++) {
|
|
948
|
+
if (i === 0) {
|
|
949
|
+
this.where(targetSearchCols[i], 'like', searchTerm);
|
|
950
|
+
} else {
|
|
951
|
+
this.orWhere(targetSearchCols[i], 'like', searchTerm);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
query = query.where(filterFn);
|
|
956
|
+
countQuery = countQuery.where(filterFn);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// Count total matches
|
|
961
|
+
const total = await countQuery.count();
|
|
962
|
+
|
|
963
|
+
// Ordering
|
|
964
|
+
if (displayField && relatedModel.columns?.has(displayField)) {
|
|
965
|
+
query = query.orderBy(displayField, 'asc');
|
|
966
|
+
} else {
|
|
967
|
+
query = query.orderBy(primaryKey, 'desc');
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// Pagination
|
|
971
|
+
if (isAll) {
|
|
972
|
+
query = query.limit(1000);
|
|
973
|
+
} else {
|
|
974
|
+
const offset = (page - 1) * limit;
|
|
975
|
+
query = query.offset(offset).limit(limit);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (selectColumns && selectColumns.length > 0) {
|
|
979
|
+
query = query.select(selectColumns);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
let records = await query.list();
|
|
983
|
+
|
|
984
|
+
// If selectedId was requested and is not present in the current paginated list, include it
|
|
985
|
+
if (selectedId !== null && selectedId !== undefined && selectedId !== '') {
|
|
986
|
+
const hasSelected = records.some(r => String(r[primaryKey]) === String(selectedId));
|
|
987
|
+
if (!hasSelected) {
|
|
988
|
+
try {
|
|
989
|
+
const findOpts = selectColumns && selectColumns.length > 0 ? { select: selectColumns } : {};
|
|
990
|
+
const selectedRecord = await relatedRepo.findById(selectedId, findOpts);
|
|
991
|
+
if (selectedRecord) {
|
|
992
|
+
records = [selectedRecord, ...records];
|
|
993
|
+
}
|
|
994
|
+
} catch (_) {
|
|
995
|
+
// Ignore if invalid ID format
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const totalPages = isAll ? 1 : Math.ceil(total / limit);
|
|
1001
|
+
const hasMore = isAll ? false : (page * limit < total);
|
|
1002
|
+
|
|
1003
|
+
res.json({
|
|
1004
|
+
data: sanitizeForOutput(records, relatedModel),
|
|
1005
|
+
displayField,
|
|
1006
|
+
primaryKey,
|
|
1007
|
+
pagination: {
|
|
1008
|
+
page,
|
|
1009
|
+
limit: isAll ? total : limit,
|
|
1010
|
+
total,
|
|
1011
|
+
totalPages,
|
|
1012
|
+
hasMore,
|
|
1013
|
+
},
|
|
1014
|
+
});
|
|
756
1015
|
} catch (error) {
|
|
757
1016
|
res.status(500).json({ error: error.message });
|
|
758
1017
|
}
|
|
@@ -833,9 +1092,12 @@ function isColumnSortable(name, meta, model) {
|
|
|
833
1092
|
relationHandler,
|
|
834
1093
|
queryHandler,
|
|
835
1094
|
resetHandler,
|
|
1095
|
+
rateLimiter,
|
|
1096
|
+
resetLoginAttempts: (ip) => rateLimiter.reset(ip),
|
|
836
1097
|
};
|
|
837
1098
|
}
|
|
838
1099
|
|
|
839
1100
|
module.exports = {
|
|
840
1101
|
createApiHandlers,
|
|
1102
|
+
LoginRateLimiter,
|
|
841
1103
|
};
|