webspresso 0.0.84 → 0.0.86
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/README.md +107 -0
- package/bin/commands/orm-map.js +1 -1
- package/core/auth/index.js +10 -42
- package/core/auth/jwt.js +235 -0
- package/core/auth/manager.js +193 -0
- package/core/auth/middleware.js +66 -4
- package/core/orm/model.js +3 -0
- package/core/orm/schema-helpers.js +1 -0
- package/index.d.ts +22 -0
- package/package.json +3 -3
- package/plugins/admin-panel/api.js +38 -2
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +2 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +18 -2
- package/plugins/admin-panel/client/parts/07-model-list.js +24 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +48 -6
- package/plugins/admin-panel/field-renderers/basic.js +18 -2
- package/plugins/admin-panel/index.js +9 -3
- package/plugins/admin-panel/modules/custom-pages.js +9 -1
- package/plugins/content/client/inline-edit.js +33 -1
- package/plugins/cors.js +171 -0
- package/plugins/csrf/index.js +8 -1
- package/plugins/index.js +2 -0
- package/src/server.js +4 -2
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +15 -7
package/core/auth/middleware.js
CHANGED
|
@@ -28,7 +28,7 @@ function createAuthMiddleware(authManager) {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Authenticate middleware
|
|
31
|
-
* Loads user from session or remember me token
|
|
31
|
+
* Loads user from Bearer JWT header, session, or remember me token
|
|
32
32
|
* Should run on every request
|
|
33
33
|
*/
|
|
34
34
|
async function authenticate(req, res, next) {
|
|
@@ -39,8 +39,27 @@ function createAuthMiddleware(authManager) {
|
|
|
39
39
|
// Create request-bound auth helper
|
|
40
40
|
req.auth = authManager.createRequestAuth(req, res);
|
|
41
41
|
|
|
42
|
-
//
|
|
43
|
-
|
|
42
|
+
// Check Bearer JWT token header first
|
|
43
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
44
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
45
|
+
const token = authHeader.substring(7).trim();
|
|
46
|
+
try {
|
|
47
|
+
const payload = authManager.verifyJwt(token);
|
|
48
|
+
req.token = token;
|
|
49
|
+
req.jwtPayload = payload;
|
|
50
|
+
if (payload.id) {
|
|
51
|
+
const user = await authManager.findUserById(payload.id);
|
|
52
|
+
req.user = user || payload;
|
|
53
|
+
} else {
|
|
54
|
+
req.user = payload;
|
|
55
|
+
}
|
|
56
|
+
} catch (jwtErr) {
|
|
57
|
+
// Invalid or expired JWT token header
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Try to load user from session if not authenticated by JWT
|
|
62
|
+
if (!req.user && req.session?.userId) {
|
|
44
63
|
const user = await authManager.findUserById(req.session.userId);
|
|
45
64
|
if (user) {
|
|
46
65
|
req.user = user;
|
|
@@ -50,7 +69,7 @@ function createAuthMiddleware(authManager) {
|
|
|
50
69
|
}
|
|
51
70
|
}
|
|
52
71
|
|
|
53
|
-
// If no user in session, try remember me token
|
|
72
|
+
// If no user in session or JWT, try remember me token
|
|
54
73
|
if (!req.user && authManager.rememberTokens) {
|
|
55
74
|
const user = await authManager.verifyRememberToken(req, res);
|
|
56
75
|
if (user) {
|
|
@@ -96,6 +115,44 @@ function createAuthMiddleware(authManager) {
|
|
|
96
115
|
};
|
|
97
116
|
}
|
|
98
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Require JWT middleware
|
|
120
|
+
* Enforces valid Bearer JWT token in Authorization header
|
|
121
|
+
* @param {Object} [options] - Verification options
|
|
122
|
+
*/
|
|
123
|
+
function requireJwt(options = {}) {
|
|
124
|
+
return async (req, res, next) => {
|
|
125
|
+
const authHeader = req.headers?.authorization || req.headers?.Authorization;
|
|
126
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
127
|
+
return res.status(401).json({
|
|
128
|
+
error: 'Unauthorized',
|
|
129
|
+
message: 'Bearer token required',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const token = authHeader.substring(7).trim();
|
|
134
|
+
try {
|
|
135
|
+
const payload = authManager.verifyJwt(token, options);
|
|
136
|
+
req.token = token;
|
|
137
|
+
req.jwtPayload = payload;
|
|
138
|
+
|
|
139
|
+
if (payload.id) {
|
|
140
|
+
const user = await authManager.findUserById(payload.id);
|
|
141
|
+
req.user = user || payload;
|
|
142
|
+
} else {
|
|
143
|
+
req.user = payload;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
next();
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return res.status(401).json({
|
|
149
|
+
error: 'Unauthorized',
|
|
150
|
+
message: err.message || 'Invalid JWT token',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
99
156
|
/**
|
|
100
157
|
* Require guest middleware
|
|
101
158
|
* Redirects authenticated users away
|
|
@@ -192,6 +249,9 @@ function createAuthMiddleware(authManager) {
|
|
|
192
249
|
case 'auth':
|
|
193
250
|
return requireAuth({ api: args.includes('api') });
|
|
194
251
|
|
|
252
|
+
case 'jwt':
|
|
253
|
+
return requireJwt();
|
|
254
|
+
|
|
195
255
|
case 'guest':
|
|
196
256
|
return requireGuest();
|
|
197
257
|
|
|
@@ -216,6 +276,7 @@ function createAuthMiddleware(authManager) {
|
|
|
216
276
|
|
|
217
277
|
// Guard middleware
|
|
218
278
|
requireAuth,
|
|
279
|
+
requireJwt,
|
|
219
280
|
requireGuest,
|
|
220
281
|
requireCan,
|
|
221
282
|
requireVerified,
|
|
@@ -225,6 +286,7 @@ function createAuthMiddleware(authManager) {
|
|
|
225
286
|
|
|
226
287
|
// Aliases for route config (factories — file-router calls requireAuth(opts) / requireGuest(opts))
|
|
227
288
|
auth: requireAuth,
|
|
289
|
+
jwt: requireJwt,
|
|
228
290
|
guest: requireGuest,
|
|
229
291
|
};
|
|
230
292
|
}
|
package/core/orm/model.js
CHANGED
|
@@ -91,6 +91,9 @@ function defineModel(options) {
|
|
|
91
91
|
icon: admin.icon || null,
|
|
92
92
|
customFields: admin.customFields || {},
|
|
93
93
|
queries: admin.queries || {},
|
|
94
|
+
sortable: admin.sortable,
|
|
95
|
+
sortableColumns: admin.sortableColumns,
|
|
96
|
+
columns: admin.columns,
|
|
94
97
|
},
|
|
95
98
|
rest: {
|
|
96
99
|
enabled: rest.enabled === true,
|
|
@@ -231,6 +231,7 @@ class SchemaBuilder {
|
|
|
231
231
|
if (options.readonly !== undefined) this._ui.readonly = options.readonly;
|
|
232
232
|
if (options.width !== undefined) this._ui.width = options.width;
|
|
233
233
|
if (options.rows !== undefined) this._ui.rows = options.rows;
|
|
234
|
+
if (options.sortable !== undefined) this._ui.sortable = options.sortable;
|
|
234
235
|
return this;
|
|
235
236
|
}
|
|
236
237
|
|
package/index.d.ts
CHANGED
|
@@ -567,6 +567,28 @@ export function swaggerPlugin(options?: Record<string, unknown>): WebspressoPlug
|
|
|
567
567
|
|
|
568
568
|
export function healthCheckPlugin(options?: Record<string, unknown>): WebspressoPlugin;
|
|
569
569
|
|
|
570
|
+
export interface CorsPluginOptions {
|
|
571
|
+
origin?:
|
|
572
|
+
| string
|
|
573
|
+
| string[]
|
|
574
|
+
| RegExp
|
|
575
|
+
| ((
|
|
576
|
+
origin: string,
|
|
577
|
+
callback: (err: Error | null, allow?: boolean | string) => void
|
|
578
|
+
) => void);
|
|
579
|
+
methods?: string | string[];
|
|
580
|
+
allowedHeaders?: string | string[];
|
|
581
|
+
exposedHeaders?: string | string[];
|
|
582
|
+
credentials?: boolean;
|
|
583
|
+
maxAge?: number;
|
|
584
|
+
preflightContinue?: boolean;
|
|
585
|
+
optionsSuccessStatus?: number;
|
|
586
|
+
routes?: string | string[] | boolean;
|
|
587
|
+
global?: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export function corsPlugin(options?: CorsPluginOptions): WebspressoPlugin;
|
|
591
|
+
|
|
570
592
|
export interface RedirectRule {
|
|
571
593
|
from: string | RegExp;
|
|
572
594
|
to: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.86",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"nodemailer": "^9.0.3",
|
|
73
73
|
"nunjucks": "^3.2.4",
|
|
74
74
|
"sanitize-html": "^2.17.3",
|
|
75
|
-
"sharp": "^0.
|
|
75
|
+
"sharp": "^0.35.3",
|
|
76
76
|
"swup": "^4.8.3",
|
|
77
77
|
"yaml": "^2.8.3",
|
|
78
78
|
"zod": "^3.23.0",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"chokidar": "^3.5.3",
|
|
117
117
|
"dotenv": "^16.3.1",
|
|
118
118
|
"express-rate-limit": "^8.4.1",
|
|
119
|
-
"release-it": "^
|
|
119
|
+
"release-it": "^20.2.1",
|
|
120
120
|
"supertest": "^6.3.4",
|
|
121
121
|
"typescript": "~5.6.3",
|
|
122
122
|
"vitest": "^3.0.0"
|
|
@@ -268,6 +268,28 @@ function createApiHandlers(options) {
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Helper to determine if a column is sortable based on model and column configs
|
|
273
|
+
*/
|
|
274
|
+
function isColumnSortable(name, meta, model) {
|
|
275
|
+
if (!meta) return false;
|
|
276
|
+
if (model.admin && model.admin.sortable === false) return false;
|
|
277
|
+
if (model.admin && Array.isArray(model.admin.sortableColumns)) {
|
|
278
|
+
return model.admin.sortableColumns.includes(name);
|
|
279
|
+
}
|
|
280
|
+
if (model.admin?.columns?.[name]?.sortable !== undefined) {
|
|
281
|
+
return Boolean(model.admin.columns[name].sortable);
|
|
282
|
+
}
|
|
283
|
+
if (meta.sortable !== undefined) {
|
|
284
|
+
return Boolean(meta.sortable);
|
|
285
|
+
}
|
|
286
|
+
if (meta.ui?.sortable !== undefined) {
|
|
287
|
+
return Boolean(meta.ui.sortable);
|
|
288
|
+
}
|
|
289
|
+
const nonSortableTypes = ['json', 'array', 'file'];
|
|
290
|
+
return !nonSortableTypes.includes(meta.type);
|
|
291
|
+
}
|
|
292
|
+
|
|
271
293
|
/**
|
|
272
294
|
* Get model metadata
|
|
273
295
|
*/
|
|
@@ -304,6 +326,7 @@ function createApiHandlers(options) {
|
|
|
304
326
|
validations: meta.validations || null,
|
|
305
327
|
ui: meta.ui ? { ...meta.ui, hidden: isHidden || meta.ui.hidden } : (isHidden ? { hidden: true } : null),
|
|
306
328
|
hidden: isHidden, // Excluded from list display and API responses
|
|
329
|
+
sortable: isColumnSortable(name, meta, model),
|
|
307
330
|
});
|
|
308
331
|
}
|
|
309
332
|
|
|
@@ -486,9 +509,22 @@ function createApiHandlers(options) {
|
|
|
486
509
|
// Apply pagination
|
|
487
510
|
query = query.offset(offset).limit(perPage);
|
|
488
511
|
|
|
489
|
-
// Apply sorting
|
|
512
|
+
// Apply sorting
|
|
490
513
|
const primaryKey = model.primaryKey || 'id';
|
|
491
|
-
|
|
514
|
+
const sortParam = req.query.sort;
|
|
515
|
+
const orderParam = (req.query.order || 'desc').toLowerCase();
|
|
516
|
+
const sortOrder = orderParam === 'asc' ? 'asc' : 'desc';
|
|
517
|
+
|
|
518
|
+
if (sortParam && model.columns.has(sortParam)) {
|
|
519
|
+
const colMeta = model.columns.get(sortParam);
|
|
520
|
+
if (isColumnSortable(sortParam, colMeta, model)) {
|
|
521
|
+
query = query.orderBy(sortParam, sortOrder);
|
|
522
|
+
} else {
|
|
523
|
+
query = query.orderBy(primaryKey, 'desc');
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
query = query.orderBy(primaryKey, 'desc');
|
|
527
|
+
}
|
|
492
528
|
|
|
493
529
|
// Get records
|
|
494
530
|
const records = await query.list();
|
|
@@ -96,6 +96,8 @@ const state = {
|
|
|
96
96
|
formData: {}, // Form field values
|
|
97
97
|
editing: false,
|
|
98
98
|
filters: {}, // Active filters { column: { op, value, from, to } }
|
|
99
|
+
sortColumn: null, // Currently sorted column name
|
|
100
|
+
sortDirection: null, // Sort direction: 'asc' | 'desc' | null
|
|
99
101
|
filterPanelOpen: false, // Filter panel visibility (deprecated)
|
|
100
102
|
filterDrawerOpen: false, // Filter drawer visibility
|
|
101
103
|
bulkFields: [], // Bulk-updatable fields (enum/boolean/date/datetime/timestamp)
|
|
@@ -300,7 +300,15 @@ const FieldRenderers = {
|
|
|
300
300
|
const label = ui.label || formatColumnLabel(col.name);
|
|
301
301
|
const placeholder = ui.placeholder || '';
|
|
302
302
|
const hint = ui.hint || '';
|
|
303
|
-
|
|
303
|
+
let dateValue = '';
|
|
304
|
+
if (value) {
|
|
305
|
+
try {
|
|
306
|
+
const d = new Date(value);
|
|
307
|
+
if (!isNaN(d.getTime())) {
|
|
308
|
+
dateValue = d.toISOString().split('T')[0];
|
|
309
|
+
}
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
304
312
|
|
|
305
313
|
return m('.mb-4', [
|
|
306
314
|
m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
|
|
@@ -329,7 +337,15 @@ const FieldRenderers = {
|
|
|
329
337
|
const label = ui.label || formatColumnLabel(col.name);
|
|
330
338
|
const placeholder = ui.placeholder || '';
|
|
331
339
|
const hint = ui.hint || '';
|
|
332
|
-
|
|
340
|
+
let dateTimeValue = '';
|
|
341
|
+
if (value) {
|
|
342
|
+
try {
|
|
343
|
+
const d = new Date(value);
|
|
344
|
+
if (!isNaN(d.getTime())) {
|
|
345
|
+
dateTimeValue = d.toISOString().slice(0, 16);
|
|
346
|
+
}
|
|
347
|
+
} catch {}
|
|
348
|
+
}
|
|
333
349
|
|
|
334
350
|
return m('.mb-4', [
|
|
335
351
|
m('label.block.text-sm.font-medium.text-gray-700 dark:text-slate-300.mb-1', { for: col.name }, label),
|
|
@@ -90,13 +90,13 @@ function formatCellValue(value, col, record = null) {
|
|
|
90
90
|
case 'timestamp':
|
|
91
91
|
try {
|
|
92
92
|
const date = new Date(value);
|
|
93
|
-
return date.toLocaleString();
|
|
93
|
+
return isNaN(date.getTime()) ? String(value) : date.toLocaleString();
|
|
94
94
|
} catch { return String(value); }
|
|
95
95
|
|
|
96
96
|
case 'date':
|
|
97
97
|
try {
|
|
98
98
|
const date = new Date(value);
|
|
99
|
-
return date.toLocaleDateString();
|
|
99
|
+
return isNaN(date.getTime()) ? String(value) : date.toLocaleDateString();
|
|
100
100
|
} catch { return String(value); }
|
|
101
101
|
|
|
102
102
|
case 'json':
|
|
@@ -523,21 +523,31 @@ function parseFilterQuery(queryString) {
|
|
|
523
523
|
return filters;
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
-
// Load records with pagination and
|
|
527
|
-
function loadRecords(modelName, page = 1, filters = null) {
|
|
526
|
+
// Load records with pagination, filters, and sorting
|
|
527
|
+
function loadRecords(modelName, page = 1, filters = null, sortColumn = undefined, sortDirection = undefined) {
|
|
528
528
|
state.loading = true;
|
|
529
529
|
state.error = null;
|
|
530
530
|
|
|
531
531
|
const perPage = state.pagination.perPage || 20;
|
|
532
532
|
const activeFilters = filters !== null ? filters : state.filters;
|
|
533
|
+
const activeSortCol = sortColumn !== undefined ? sortColumn : state.sortColumn;
|
|
534
|
+
const activeSortDir = sortDirection !== undefined ? sortDirection : state.sortDirection;
|
|
535
|
+
|
|
536
|
+
state.sortColumn = activeSortCol;
|
|
537
|
+
state.sortDirection = activeSortDir;
|
|
538
|
+
|
|
533
539
|
const filterQuery = buildFilterQuery(activeFilters);
|
|
534
540
|
|
|
535
|
-
// Update URL with filters
|
|
541
|
+
// Update URL with filters and sort params
|
|
536
542
|
const queryParams = new URLSearchParams();
|
|
537
543
|
queryParams.set('page', page);
|
|
538
544
|
if (state.trashedView) {
|
|
539
545
|
queryParams.set('trashed', 'only');
|
|
540
546
|
}
|
|
547
|
+
if (activeSortCol && activeSortDir) {
|
|
548
|
+
queryParams.set('sort', activeSortCol);
|
|
549
|
+
queryParams.set('order', activeSortDir);
|
|
550
|
+
}
|
|
541
551
|
if (Object.keys(activeFilters).length > 0) {
|
|
542
552
|
for (const [col, filter] of Object.entries(activeFilters)) {
|
|
543
553
|
if (!filter || !filterPayloadIsActive(filter)) continue;
|
|
@@ -566,7 +576,8 @@ function loadRecords(modelName, page = 1, filters = null) {
|
|
|
566
576
|
window.history.replaceState({}, '', newUrl);
|
|
567
577
|
|
|
568
578
|
const trashedParam = state.trashedView ? '&trashed=only' : '';
|
|
569
|
-
|
|
579
|
+
const sortParam = activeSortCol && activeSortDir ? '&sort=' + encodeURIComponent(activeSortCol) + '&order=' + encodeURIComponent(activeSortDir) : '';
|
|
580
|
+
return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery + sortParam)
|
|
570
581
|
.then(result => {
|
|
571
582
|
state.records = result.data || [];
|
|
572
583
|
state.pagination = {
|
|
@@ -601,11 +612,16 @@ function initializeModelView(modelName) {
|
|
|
601
612
|
state.selectedBulkField = null;
|
|
602
613
|
state._currentModelName = modelName;
|
|
603
614
|
|
|
604
|
-
// Parse filters from URL query string
|
|
615
|
+
// Parse filters and sorting from URL query string
|
|
605
616
|
const urlParams = new URLSearchParams(window.location.search);
|
|
606
617
|
const page = parseInt(urlParams.get('page')) || 1;
|
|
607
618
|
state.filters = parseFilterQuery(window.location.search);
|
|
608
619
|
|
|
620
|
+
const rawSort = urlParams.get('sort');
|
|
621
|
+
const rawOrder = (urlParams.get('order') || '').toLowerCase();
|
|
622
|
+
state.sortColumn = rawSort || null;
|
|
623
|
+
state.sortDirection = rawOrder === 'asc' ? 'asc' : rawOrder === 'desc' ? 'desc' : null;
|
|
624
|
+
|
|
609
625
|
// Load model metadata first, then records
|
|
610
626
|
state.loading = true;
|
|
611
627
|
api.get('/models/' + modelName)
|
|
@@ -614,7 +630,7 @@ function initializeModelView(modelName) {
|
|
|
614
630
|
state.currentModel = modelMeta;
|
|
615
631
|
// Load bulk-updatable fields for this model
|
|
616
632
|
loadBulkFields(modelName);
|
|
617
|
-
return loadRecords(modelName, page, state.filters);
|
|
633
|
+
return loadRecords(modelName, page, state.filters, state.sortColumn, state.sortDirection);
|
|
618
634
|
})
|
|
619
635
|
.catch(err => {
|
|
620
636
|
state.error = err.message;
|
|
@@ -441,12 +441,54 @@ const RecordList = {
|
|
|
441
441
|
}),
|
|
442
442
|
]),
|
|
443
443
|
// Dynamic column headers (first column sticky left with box-shadow)
|
|
444
|
-
...displayColumns.map((col, i) =>
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
)
|
|
449
|
-
|
|
444
|
+
...displayColumns.map((col, i) => {
|
|
445
|
+
const isSortable = col.sortable !== false;
|
|
446
|
+
const isSorted = isSortable && state.sortColumn === col.name;
|
|
447
|
+
const dir = isSorted ? state.sortDirection : null;
|
|
448
|
+
const stickyStyle = i === 0 ? 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' : '';
|
|
449
|
+
|
|
450
|
+
if (!isSortable) {
|
|
451
|
+
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700',
|
|
452
|
+
stickyStyle ? { style: stickyStyle } : {},
|
|
453
|
+
formatColumnLabel(col.name)
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return m('th.px-4.py-3.text-left.text-xs.font-medium.text-gray-500.dark:text-slate-400.uppercase.tracking-wider.whitespace-nowrap.bg-gray-50.dark:bg-slate-900.border-b.border-gray-200.dark:border-slate-700.cursor-pointer.select-none.hover:bg-gray-100.dark:hover:bg-slate-800/80.transition-colors.group',
|
|
458
|
+
{
|
|
459
|
+
style: stickyStyle || undefined,
|
|
460
|
+
onclick: () => {
|
|
461
|
+
let nextSort = col.name;
|
|
462
|
+
let nextDir = 'asc';
|
|
463
|
+
if (state.sortColumn === col.name) {
|
|
464
|
+
if (state.sortDirection === 'asc') {
|
|
465
|
+
nextDir = 'desc';
|
|
466
|
+
} else if (state.sortDirection === 'desc') {
|
|
467
|
+
nextSort = null;
|
|
468
|
+
nextDir = null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
state.sortColumn = nextSort;
|
|
472
|
+
state.sortDirection = nextDir;
|
|
473
|
+
loadRecords(modelName, 1, state.filters, nextSort, nextDir);
|
|
474
|
+
},
|
|
475
|
+
},
|
|
476
|
+
m('.flex.items-center.gap-1.5', [
|
|
477
|
+
m('span', formatColumnLabel(col.name)),
|
|
478
|
+
dir === 'asc'
|
|
479
|
+
? m('svg.w-3.5.h-3.5.text-indigo-600.dark:text-indigo-400.flex-shrink-0', { fill: 'currentColor', viewBox: '0 0 20 20' },
|
|
480
|
+
m('path', { 'fill-rule': 'evenodd', d: 'M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z', 'clip-rule': 'evenodd' })
|
|
481
|
+
)
|
|
482
|
+
: dir === 'desc'
|
|
483
|
+
? m('svg.w-3.5.h-3.5.text-indigo-600.dark:text-indigo-400.flex-shrink-0', { fill: 'currentColor', viewBox: '0 0 20 20' },
|
|
484
|
+
m('path', { 'fill-rule': 'evenodd', d: 'M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z', 'clip-rule': 'evenodd' })
|
|
485
|
+
)
|
|
486
|
+
: m('svg.w-3.5.h-3.5.text-gray-300.dark:text-slate-600.group-hover:text-gray-400.flex-shrink-0', { fill: 'currentColor', viewBox: '0 0 20 20' },
|
|
487
|
+
m('path', { d: 'M5 12a1 1 0 100 2h10a1 1 0 100-2H5zM5 6a1 1 0 100 2h10a1 1 0 100-2H5z' })
|
|
488
|
+
)
|
|
489
|
+
])
|
|
490
|
+
);
|
|
491
|
+
}),
|
|
450
492
|
// Sticky actions header (sticky right, box-shadow on left)
|
|
451
493
|
m('th.px-4.py-3.text-right.text-xs.font-medium.text-gray-500 dark:text-slate-400.uppercase.tracking-wider.bg-gray-50 dark:bg-slate-900.border-b.border-gray-200 dark:border-slate-700', {
|
|
452
494
|
style: 'position: sticky; right: 0; min-width: 120px; z-index: 15; box-shadow: -4px 0 8px -4px rgba(0,0,0,0.08);',
|
|
@@ -121,7 +121,15 @@ module.exports = {
|
|
|
121
121
|
DateField: {
|
|
122
122
|
view: (vnode) => {
|
|
123
123
|
const { name, value = '', meta = {}, required = false } = vnode.attrs;
|
|
124
|
-
|
|
124
|
+
let dateValue = '';
|
|
125
|
+
if (value) {
|
|
126
|
+
try {
|
|
127
|
+
const d = new Date(value);
|
|
128
|
+
if (!isNaN(d.getTime())) {
|
|
129
|
+
dateValue = d.toISOString().split('T')[0];
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
125
133
|
return m('.mb-4', [
|
|
126
134
|
m('label.block.text-sm.font-medium.mb-2', { for: name },
|
|
127
135
|
meta.label || name,
|
|
@@ -149,7 +157,15 @@ module.exports = {
|
|
|
149
157
|
DateTimeField: {
|
|
150
158
|
view: (vnode) => {
|
|
151
159
|
const { name, value = '', meta = {}, required = false } = vnode.attrs;
|
|
152
|
-
|
|
160
|
+
let dateTimeValue = '';
|
|
161
|
+
if (value) {
|
|
162
|
+
try {
|
|
163
|
+
const d = new Date(value);
|
|
164
|
+
if (!isNaN(d.getTime())) {
|
|
165
|
+
dateTimeValue = d.toISOString().slice(0, 16);
|
|
166
|
+
}
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
153
169
|
return m('.mb-4', [
|
|
154
170
|
m('label.block.text-sm.font-medium.mb-2', { for: name },
|
|
155
171
|
meta.label || name,
|
|
@@ -71,10 +71,15 @@ function adminPanelPlugin(options = {}) {
|
|
|
71
71
|
version: '2.0.0',
|
|
72
72
|
description: 'Modular admin panel for Webspresso with extensions support',
|
|
73
73
|
|
|
74
|
-
// CSP requirements for admin panel scripts
|
|
74
|
+
// CSP requirements for admin panel scripts, styles, and custom page iframe sources
|
|
75
75
|
// Note: cdn.quilljs.com 301-redirects to cdn.jsdelivr.net; CSP is enforced on the final URL.
|
|
76
76
|
csp: {
|
|
77
|
-
styleSrc: [
|
|
77
|
+
styleSrc: [
|
|
78
|
+
'https://cdn.quilljs.com',
|
|
79
|
+
'https://cdn.jsdelivr.net',
|
|
80
|
+
'https://unpkg.com',
|
|
81
|
+
'https://cdn.tailwindcss.com',
|
|
82
|
+
],
|
|
78
83
|
scriptSrc: [
|
|
79
84
|
'https://cdn.quilljs.com',
|
|
80
85
|
'https://cdn.jsdelivr.net',
|
|
@@ -82,6 +87,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
82
87
|
'https://cdn.tailwindcss.com',
|
|
83
88
|
],
|
|
84
89
|
connectSrc: ['https://unpkg.com', 'https://cdn.tailwindcss.com', 'https://cdn.jsdelivr.net'],
|
|
90
|
+
frameSrc: ["'self'", 'data:', 'blob:', 'https:'],
|
|
85
91
|
},
|
|
86
92
|
enabled,
|
|
87
93
|
registry, // Expose registry for external configuration
|
|
@@ -281,7 +287,7 @@ function adminPanelPlugin(options = {}) {
|
|
|
281
287
|
|
|
282
288
|
// Custom pages API routes
|
|
283
289
|
ctx.addRoute('get', `${adminPath}/api/extensions/pages/:pageId/data`, requireAuth, pageHandlers.getPageData);
|
|
284
|
-
ctx.addRoute('
|
|
290
|
+
ctx.addRoute('all', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
|
|
285
291
|
|
|
286
292
|
// User management API routes
|
|
287
293
|
if (userHandlers) {
|
|
@@ -128,7 +128,15 @@ function createCustomPageApiHandlers(options) {
|
|
|
128
128
|
return res.status(404).json({ error: 'Action not found' });
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const payload = { ...(req.query || {}), ...(req.body || {}) };
|
|
132
|
+
const result = await action({
|
|
133
|
+
db,
|
|
134
|
+
req,
|
|
135
|
+
body: payload,
|
|
136
|
+
payload,
|
|
137
|
+
query: req.query || {},
|
|
138
|
+
user: req.session?.adminUser,
|
|
139
|
+
});
|
|
132
140
|
res.json({ success: true, result });
|
|
133
141
|
} catch (error) {
|
|
134
142
|
res.status(500).json({ error: error.message });
|
|
@@ -243,6 +243,38 @@
|
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
function sanitizeClientHtml(htmlStr) {
|
|
247
|
+
if (!htmlStr) return '';
|
|
248
|
+
try {
|
|
249
|
+
var parser = new DOMParser();
|
|
250
|
+
var doc = parser.parseFromString(htmlStr, 'text/html');
|
|
251
|
+
var forbiddenTags = ['script', 'iframe', 'object', 'embed', 'applet', 'base'];
|
|
252
|
+
forbiddenTags.forEach(function (tag) {
|
|
253
|
+
var nodes = doc.querySelectorAll(tag);
|
|
254
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
255
|
+
nodes[i].parentNode.removeChild(nodes[i]);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
var allNodes = doc.querySelectorAll('*');
|
|
259
|
+
for (var i = 0; i < allNodes.length; i++) {
|
|
260
|
+
var node = allNodes[i];
|
|
261
|
+
var attrs = Array.prototype.slice.call(node.attributes);
|
|
262
|
+
for (var j = 0; j < attrs.length; j++) {
|
|
263
|
+
var attrName = attrs[j].name.toLowerCase();
|
|
264
|
+
var attrVal = attrs[j].value.toLowerCase().trim();
|
|
265
|
+
if (attrName.indexOf('on') === 0) {
|
|
266
|
+
node.removeAttribute(attrs[j].name);
|
|
267
|
+
} else if ((attrName === 'href' || attrName === 'src') && attrVal.indexOf('javascript:') === 0) {
|
|
268
|
+
node.removeAttribute(attrs[j].name);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return doc.body.innerHTML;
|
|
273
|
+
} catch (e) {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
246
278
|
function updateDomFields(entryId, data) {
|
|
247
279
|
qsa('[data-ws-content-entry="' + entryId + '"][data-ws-content-field]').forEach(function (el) {
|
|
248
280
|
var field = el.getAttribute('data-ws-content-field');
|
|
@@ -250,7 +282,7 @@
|
|
|
250
282
|
var val = data[field];
|
|
251
283
|
var isHtml = el.getAttribute('data-ws-content-html') === 'true';
|
|
252
284
|
if (isHtml) {
|
|
253
|
-
el.innerHTML = val
|
|
285
|
+
el.innerHTML = sanitizeClientHtml(val);
|
|
254
286
|
} else {
|
|
255
287
|
el.textContent = val != null ? val : '';
|
|
256
288
|
}
|