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.
@@ -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
- // Try to load user from session
43
- if (req.session?.userId) {
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.84",
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.33.5",
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": "^19.0.0",
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 (newest first by default)
512
+ // Apply sorting
490
513
  const primaryKey = model.primaryKey || 'id';
491
- query = query.orderBy(primaryKey, 'desc');
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
- const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
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
- const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
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 filters
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
- return api.get('/models/' + modelName + '/records?page=' + page + '&perPage=' + perPage + trashedParam + filterQuery)
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
- 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',
446
- i === 0 ? { style: 'position: sticky; left: 40px; z-index: 15; box-shadow: 4px 0 8px -4px rgba(0,0,0,0.08);' } : {},
447
- formatColumnLabel(col.name)
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
- const dateValue = value ? new Date(value).toISOString().split('T')[0] : '';
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
- const dateTimeValue = value ? new Date(value).toISOString().slice(0, 16) : '';
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: ['https://cdn.quilljs.com', 'https://cdn.jsdelivr.net'],
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('post', `${adminPath}/api/extensions/pages/:pageId/actions/:actionId`, requireAuth, pageHandlers.executePageAction);
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 result = await action({ db, req, body: req.body, user: req.session?.adminUser });
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
  }