webspresso 0.0.90 → 0.0.93

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.
Files changed (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Webspresso Background Job Queue Plugin
3
+ * @module plugins/queue
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const path = require('path');
9
+ const fs = require('fs');
10
+ const {
11
+ createQueueManager,
12
+ MemoryQueueAdapter,
13
+ DatabaseQueueAdapter,
14
+ RedisQueueAdapter,
15
+ } = require('../../core/queue');
16
+
17
+ /**
18
+ * @param {Object} [options]
19
+ * @param {'memory'|'database'|'redis'|Object} [options.adapter='memory'] - Queue backend adapter or name
20
+ * @param {Object} [options.db] - Knex/ORM database instance for database adapter
21
+ * @param {Object} [options.redisClient] - Redis client instance for redis adapter
22
+ * @param {string} [options.tableName='webspresso_jobs'] - Database queue table name
23
+ * @param {number} [options.concurrency=2] - Worker concurrency
24
+ * @param {number} [options.pollInterval=500] - Idle poll interval in ms
25
+ * @param {string} [options.jobsDir] - Path to jobs directory (defaults to <cwd>/jobs)
26
+ * @param {boolean} [options.autoStart=true] - Auto start processing
27
+ * @returns {Object} Plugin definition
28
+ */
29
+ function queuePlugin(options = {}) {
30
+ const concurrency = options.concurrency || 2;
31
+ const pollInterval = options.pollInterval || 500;
32
+ const autoStart = options.autoStart !== false;
33
+ const jobsDir = options.jobsDir || path.join(process.cwd(), 'jobs');
34
+
35
+ let queueManager = null;
36
+
37
+ return {
38
+ name: 'queue',
39
+ version: '1.0.0',
40
+ description: 'Background Job Queue Engine with Memory, DB, and Redis adapters',
41
+
42
+ register(ctx) {
43
+ const { app, db } = ctx;
44
+
45
+ let adapter = options.adapter;
46
+
47
+ if (!adapter || adapter === 'memory') {
48
+ adapter = new MemoryQueueAdapter();
49
+ } else if (adapter === 'database' || adapter === 'db') {
50
+ const knexInstance = options.db?.knex || db?.knex || options.knex;
51
+ if (!knexInstance) {
52
+ throw new Error('[queuePlugin] Database queue adapter requires a valid database/knex instance.');
53
+ }
54
+ adapter = new DatabaseQueueAdapter({
55
+ knex: knexInstance,
56
+ tableName: options.tableName || 'webspresso_jobs',
57
+ });
58
+ } else if (adapter === 'redis') {
59
+ const client = options.redisClient || options.redis;
60
+ if (!client) {
61
+ throw new Error('[queuePlugin] Redis queue adapter requires a valid redisClient in options.');
62
+ }
63
+ adapter = new RedisQueueAdapter({
64
+ client,
65
+ prefix: options.prefix,
66
+ });
67
+ }
68
+
69
+ queueManager = createQueueManager({
70
+ adapter,
71
+ concurrency,
72
+ pollInterval,
73
+ jobsDir,
74
+ autoStart,
75
+ });
76
+
77
+ // Expose to ctx & app
78
+ ctx.queue = queueManager;
79
+ if (app) {
80
+ app.queue = queueManager;
81
+ app.set('webspresso.queue', queueManager);
82
+
83
+ // Attach req.queue middleware
84
+ app.use((req, res, next) => {
85
+ req.queue = queueManager;
86
+ next();
87
+ });
88
+ }
89
+
90
+ this.queue = queueManager;
91
+ },
92
+
93
+ onRoutesReady(ctx) {
94
+ if (jobsDir && fs.existsSync(jobsDir)) {
95
+ queueManager.loadJobsFromDirectory(jobsDir);
96
+ }
97
+ },
98
+
99
+ async dispose() {
100
+ if (queueManager) {
101
+ await queueManager.drain(5000);
102
+ }
103
+ },
104
+ };
105
+ }
106
+
107
+ module.exports = queuePlugin;
108
+ module.exports.queuePlugin = queuePlugin;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Webspresso Realtime Plugin
3
+ * Realtime plugin for Webspresso applications
4
+ * @module plugins/realtime
5
+ */
6
+
7
+ const {
8
+ createRealtime,
9
+ RealtimeClient,
10
+ RealtimeSubscription,
11
+ SubscriptionRegistry,
12
+ createSubscriptionKey,
13
+ AuthManager,
14
+ ReconnectManager,
15
+ RealtimeError,
16
+ normalizeRealtimeError,
17
+ sanitizeErrorMessage,
18
+ EventEmitter,
19
+ DEFAULT_CAPABILITIES,
20
+
21
+ // Adapters
22
+ websocket,
23
+ createWebSocketAdapter,
24
+ WebSocketAdapter,
25
+ resolveWebSocketUrl,
26
+
27
+ sse,
28
+ createSseAdapter,
29
+ SseAdapter,
30
+ resolveSseUrl,
31
+
32
+ socketIo,
33
+ createSocketIoAdapter,
34
+ SocketIoAdapter,
35
+
36
+ redis,
37
+ createRedisAdapter,
38
+ RedisAdapter,
39
+ } = require('../../core/realtime');
40
+
41
+ /**
42
+ * Webspresso Realtime Plugin Factory
43
+ *
44
+ * @param {Object} options
45
+ * @returns {Object} Webspresso plugin definition
46
+ */
47
+ function realtimePlugin(options = {}) {
48
+ let clientInstance = null;
49
+
50
+ return {
51
+ name: 'realtime',
52
+ version: '1.0.0',
53
+ description: 'Framework-agnostic, plugin-based realtime layer for Webspresso',
54
+ _options: options,
55
+
56
+ api: {
57
+ getClient: () => clientInstance,
58
+ },
59
+
60
+ setup(app) {
61
+ if (!clientInstance) {
62
+ clientInstance = createRealtime(options);
63
+ }
64
+
65
+ if (app) {
66
+ app.realtime = clientInstance;
67
+ }
68
+
69
+ return () => {
70
+ if (clientInstance) {
71
+ clientInstance.destroy();
72
+ }
73
+ };
74
+ },
75
+
76
+ register(ctx) {
77
+ if (!clientInstance) {
78
+ clientInstance = createRealtime(options);
79
+ }
80
+
81
+ if (ctx.app) {
82
+ ctx.app.realtime = clientInstance;
83
+ }
84
+
85
+ if (typeof ctx.onDispose === 'function') {
86
+ ctx.onDispose(() => {
87
+ if (clientInstance) {
88
+ clientInstance.destroy();
89
+ }
90
+ });
91
+ }
92
+ },
93
+
94
+ browserReady(app) {
95
+ if (clientInstance) {
96
+ clientInstance.browserReady();
97
+ }
98
+ },
99
+
100
+ destroy(app) {
101
+ if (clientInstance) {
102
+ clientInstance.destroy();
103
+ clientInstance = null;
104
+ }
105
+ },
106
+ };
107
+ }
108
+
109
+ function realtime(options = {}) {
110
+ const client = createRealtime(options);
111
+ client.plugin = realtimePlugin(options);
112
+ return client;
113
+ }
114
+
115
+ module.exports = {
116
+ realtimePlugin,
117
+ realtime,
118
+ createRealtime,
119
+ RealtimeClient,
120
+ RealtimeSubscription,
121
+ SubscriptionRegistry,
122
+ createSubscriptionKey,
123
+ AuthManager,
124
+ ReconnectManager,
125
+ RealtimeError,
126
+ normalizeRealtimeError,
127
+ sanitizeErrorMessage,
128
+ EventEmitter,
129
+ DEFAULT_CAPABILITIES,
130
+
131
+ // Adapters
132
+ websocket,
133
+ createWebSocketAdapter,
134
+ WebSocketAdapter,
135
+ resolveWebSocketUrl,
136
+
137
+ sse,
138
+ createSseAdapter,
139
+ SseAdapter,
140
+ resolveSseUrl,
141
+
142
+ socketIo,
143
+ createSocketIoAdapter,
144
+ SocketIoAdapter,
145
+
146
+ redis,
147
+ createRedisAdapter,
148
+ RedisAdapter,
149
+ };
150
+
@@ -107,6 +107,17 @@ function recaptchaPlugin(options = {}) {
107
107
  },
108
108
 
109
109
  register(ctx) {
110
+ if (ctx.middlewares) {
111
+ ctx.middlewares.recaptcha = (mwOpts = {}) =>
112
+ createRecaptchaMiddleware({
113
+ secret: resolveSecret(),
114
+ bodyField: mwOpts.bodyField,
115
+ version: mwOpts.version || version,
116
+ minScore: mwOpts.minScore ?? minScore,
117
+ expectedAction: mwOpts.expectedAction ?? expectedAction,
118
+ });
119
+ }
120
+
110
121
  ctx.addHelper('recaptchaScript', () =>
111
122
  version === 'v3' ? recaptchaScriptTag(siteKey) : recaptchaScriptTag()
112
123
  );
@@ -12,7 +12,15 @@ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
12
12
  function isExternalTarget(to) {
13
13
  if (!to || typeof to !== 'string') return false;
14
14
  const t = to.trim();
15
- return /^https?:\/\//i.test(t) || t.startsWith('//');
15
+ // Protocol-relative (//, \\, /\, \/) or URL schemes (http:, https:, javascript:, data:, vbscript:, etc.)
16
+ return (
17
+ /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(t) ||
18
+ t.startsWith('//') ||
19
+ t.startsWith('\\\\') ||
20
+ t.startsWith('/\\') ||
21
+ t.startsWith('\\/') ||
22
+ t.startsWith('\\')
23
+ );
16
24
  }
17
25
 
18
26
  /**
@@ -7,6 +7,7 @@ const { attachDbMiddleware } = require('../../src/app-context');
7
7
  const { getAllModels } = require('../../core/orm/model');
8
8
  const { omit } = require('../../core/orm/utils');
9
9
  const { trimUrlPathSlashes } = require('../../core/url-path-normalize');
10
+ const { validateQueryComplexity } = require('../../core/orm/complexity');
10
11
 
11
12
  const RESERVED_QUERY_KEYS = new Set(['page', 'perPage', 'sort', 'order', 'include', 'trashed']);
12
13
 
@@ -167,22 +168,276 @@ function applySoftDeleteScope(query, countQuery, model, trashed) {
167
168
  return { query, countQuery };
168
169
  }
169
170
 
171
+ /**
172
+ * Parse sort parameters into an array of { column, direction }
173
+ * @param {import('../../core/orm/types').ModelDefinition} model
174
+ * @param {*} rawSort
175
+ * @param {*} rawOrder
176
+ * @returns {Array<{ column: string, direction: 'asc'|'desc' }>}
177
+ */
178
+ function parseSortParams(model, rawSort, rawOrder) {
179
+ const result = [];
180
+ const seen = new Set();
181
+
182
+ function addSort(col, dir) {
183
+ if (!col || typeof col !== 'string') return;
184
+ const cleanCol = col.trim();
185
+ if (!model.columns.has(cleanCol) || seen.has(cleanCol)) return;
186
+ seen.add(cleanCol);
187
+ const cleanDir = String(dir || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc';
188
+ result.push({ column: cleanCol, direction: cleanDir });
189
+ }
190
+
191
+ if (Array.isArray(rawSort)) {
192
+ for (const item of rawSort) {
193
+ if (typeof item === 'string') {
194
+ const tokens = item.split(',').map((s) => s.trim()).filter(Boolean);
195
+ for (const token of tokens) {
196
+ if (token.startsWith('-')) {
197
+ addSort(token.slice(1), 'desc');
198
+ } else if (token.startsWith('+')) {
199
+ addSort(token.slice(1), 'asc');
200
+ } else if (token.includes(':')) {
201
+ const [c, d] = token.split(':');
202
+ addSort(c, d);
203
+ } else {
204
+ addSort(token, rawOrder || 'asc');
205
+ }
206
+ }
207
+ }
208
+ }
209
+ } else if (typeof rawSort === 'string' && rawSort.trim() !== '') {
210
+ const tokens = rawSort.split(',').map((s) => s.trim()).filter(Boolean);
211
+ for (const token of tokens) {
212
+ if (token.startsWith('-')) {
213
+ addSort(token.slice(1), 'desc');
214
+ } else if (token.startsWith('+')) {
215
+ addSort(token.slice(1), 'asc');
216
+ } else if (token.includes(':')) {
217
+ const [c, d] = token.split(':');
218
+ addSort(c, d);
219
+ } else if (tokens.length === 1 && rawOrder) {
220
+ addSort(token, rawOrder);
221
+ } else {
222
+ addSort(token, 'asc');
223
+ }
224
+ }
225
+ }
226
+
227
+ if (result.length === 0) {
228
+ result.push({
229
+ column: model.primaryKey,
230
+ direction: String(rawOrder || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc',
231
+ });
232
+ }
233
+
234
+ return result;
235
+ }
236
+
170
237
  function applyColumnFilters(query, countQuery, model, req) {
171
238
  let q = query;
172
239
  let c = countQuery;
240
+
241
+ function applyCondition(col, op, rawVal) {
242
+ if (!model.columns.has(col)) return;
243
+ const meta = model.columns.get(col);
244
+ const colType = meta?.type;
245
+
246
+ let operator = String(op || 'eq').toLowerCase();
247
+ let val = rawVal;
248
+
249
+ // Normalize booleans
250
+ if (colType === 'boolean' && typeof val === 'string') {
251
+ if (val === 'true' || val === '1') val = true;
252
+ else if (val === 'false' || val === '0') val = false;
253
+ }
254
+
255
+ switch (operator) {
256
+ case 'eq':
257
+ case '=':
258
+ if (val !== undefined && val !== '') {
259
+ q = q.where(col, '=', val);
260
+ c = c.where(col, '=', val);
261
+ }
262
+ break;
263
+
264
+ case 'ne':
265
+ case '!=':
266
+ case '<>':
267
+ if (val !== undefined && val !== '') {
268
+ q = q.where(col, '!=', val);
269
+ c = c.where(col, '!=', val);
270
+ }
271
+ break;
272
+
273
+ case 'gt':
274
+ case '>':
275
+ if (val !== undefined && val !== '') {
276
+ q = q.where(col, '>', val);
277
+ c = c.where(col, '>', val);
278
+ }
279
+ break;
280
+
281
+ case 'gte':
282
+ case '>=':
283
+ if (val !== undefined && val !== '') {
284
+ q = q.where(col, '>=', val);
285
+ c = c.where(col, '>=', val);
286
+ }
287
+ break;
288
+
289
+ case 'lt':
290
+ case '<':
291
+ if (val !== undefined && val !== '') {
292
+ q = q.where(col, '<', val);
293
+ c = c.where(col, '<', val);
294
+ }
295
+ break;
296
+
297
+ case 'lte':
298
+ case '<=':
299
+ if (val !== undefined && val !== '') {
300
+ q = q.where(col, '<=', val);
301
+ c = c.where(col, '<=', val);
302
+ }
303
+ break;
304
+
305
+ case 'in': {
306
+ const arr = Array.isArray(val)
307
+ ? val
308
+ : (typeof val === 'string' ? val.split(',').map(s => s.trim()).filter(Boolean) : [val]);
309
+ if (arr.length > 0) {
310
+ q = q.whereIn(col, arr);
311
+ c = c.whereIn(col, arr);
312
+ }
313
+ break;
314
+ }
315
+
316
+ case 'nin':
317
+ case 'notin': {
318
+ const arr = Array.isArray(val)
319
+ ? val
320
+ : (typeof val === 'string' ? val.split(',').map(s => s.trim()).filter(Boolean) : [val]);
321
+ if (arr.length > 0) {
322
+ q = q.whereNotIn(col, arr);
323
+ c = c.whereNotIn(col, arr);
324
+ }
325
+ break;
326
+ }
327
+
328
+ case 'like':
329
+ if (val !== undefined && val !== '') {
330
+ q = q.where(col, 'like', String(val));
331
+ c = c.where(col, 'like', String(val));
332
+ }
333
+ break;
334
+
335
+ case 'ilike':
336
+ if (val !== undefined && val !== '') {
337
+ q = q.where(col, 'ilike', String(val));
338
+ c = c.where(col, 'ilike', String(val));
339
+ }
340
+ break;
341
+
342
+ case 'contains':
343
+ if (val !== undefined && val !== '') {
344
+ q = q.where(col, 'like', `%${String(val)}%`);
345
+ c = c.where(col, 'like', `%${String(val)}%`);
346
+ }
347
+ break;
348
+
349
+ case 'startswith':
350
+ if (val !== undefined && val !== '') {
351
+ q = q.where(col, 'like', `${String(val)}%`);
352
+ c = c.where(col, 'like', `${String(val)}%`);
353
+ }
354
+ break;
355
+
356
+ case 'endswith':
357
+ if (val !== undefined && val !== '') {
358
+ q = q.where(col, 'like', `%${String(val)}`);
359
+ c = c.where(col, 'like', `%${String(val)}`);
360
+ }
361
+ break;
362
+
363
+ case 'isnull':
364
+ case 'is_null': {
365
+ const isTrue = val === true || val === 'true' || val === '1' || val === 1 || val === '';
366
+ if (isTrue) {
367
+ q = q.whereNull(col);
368
+ c = c.whereNull(col);
369
+ }
370
+ break;
371
+ }
372
+
373
+ case 'isnotnull':
374
+ case 'is_not_null': {
375
+ const isTrue = val === true || val === 'true' || val === '1' || val === 1 || val === '';
376
+ if (isTrue) {
377
+ q = q.whereNotNull(col);
378
+ c = c.whereNotNull(col);
379
+ }
380
+ break;
381
+ }
382
+
383
+ case 'between': {
384
+ let parts = [];
385
+ if (Array.isArray(val)) {
386
+ parts = val;
387
+ } else if (typeof val === 'string') {
388
+ parts = val.split(',').map(s => s.trim());
389
+ }
390
+ if (parts.length === 2 && parts[0] !== '' && parts[1] !== '') {
391
+ q = q.where(col, '>=', parts[0]).where(col, '<=', parts[1]);
392
+ c = c.where(col, '>=', parts[0]).where(col, '<=', parts[1]);
393
+ }
394
+ break;
395
+ }
396
+
397
+ default:
398
+ if (val !== undefined && val !== '') {
399
+ q = q.where(col, '=', val);
400
+ c = c.where(col, '=', val);
401
+ }
402
+ break;
403
+ }
404
+ }
405
+
406
+ // 1. Process dedicated filter object ?filter[col]=... or ?filter[col][op]=...
407
+ if (req.query.filter && typeof req.query.filter === 'object') {
408
+ for (const [col, colFilter] of Object.entries(req.query.filter)) {
409
+ if (!model.columns.has(col)) continue;
410
+ if (colFilter && typeof colFilter === 'object' && !Array.isArray(colFilter)) {
411
+ if (colFilter.op !== undefined && colFilter.value !== undefined) {
412
+ applyCondition(col, colFilter.op, colFilter.value);
413
+ } else {
414
+ for (const [op, val] of Object.entries(colFilter)) {
415
+ applyCondition(col, op, val);
416
+ }
417
+ }
418
+ } else if (colFilter !== undefined && colFilter !== '') {
419
+ applyCondition(col, 'eq', colFilter);
420
+ }
421
+ }
422
+ }
423
+
424
+ // 2. Process top-level query parameters ?price[gte]=100 or ?status=active
173
425
  for (const [key, value] of Object.entries(req.query)) {
174
- if (RESERVED_QUERY_KEYS.has(key)) {
426
+ if (RESERVED_QUERY_KEYS.has(key) || key === 'filter') {
175
427
  continue;
176
428
  }
177
429
  if (!model.columns.has(key)) {
178
430
  continue;
179
431
  }
180
- if (value === undefined || value === '') {
181
- continue;
432
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
433
+ for (const [op, val] of Object.entries(value)) {
434
+ applyCondition(key, op, val);
435
+ }
436
+ } else if (value !== undefined && value !== '') {
437
+ applyCondition(key, 'eq', value);
182
438
  }
183
- q = q.where(key, value);
184
- c = c.where(key, value);
185
439
  }
440
+
186
441
  return { query: q, countQuery: c };
187
442
  }
188
443
 
@@ -235,21 +490,29 @@ function restResourcePlugin(options = {}) {
235
490
  ...chain(async (req, res) => {
236
491
  try {
237
492
  const repo = db.getRepository(model.name);
493
+ const complexity = validateQueryComplexity(model, {
494
+ perPage: req.query.perPage,
495
+ includes: parseIncludeParam(model, req.query.include),
496
+ strict: false,
497
+ });
238
498
  const page = Math.max(1, parseInt(req.query.page, 10) || 1);
239
- const perPage = Math.min(100, Math.max(1, parseInt(req.query.perPage, 10) || 15));
499
+ const perPage = complexity.perPage;
240
500
  const offset = (page - 1) * perPage;
241
- const include = parseIncludeParam(model, req.query.include);
501
+ const include = complexity.includes;
242
502
 
243
503
  let query = repo.query();
244
504
  let countQuery = repo.query();
245
505
  ({ query, countQuery } = applySoftDeleteScope(query, countQuery, model, req.query.trashed));
246
506
  ({ query, countQuery } = applyColumnFilters(query, countQuery, model, req));
247
507
 
248
- const sortCol = req.query.sort && model.columns.has(req.query.sort) ? req.query.sort : model.primaryKey;
249
- const order = String(req.query.order || 'desc').toLowerCase() === 'asc' ? 'asc' : 'desc';
508
+ const sortEntries = parseSortParams(model, req.query.sort, req.query.order);
250
509
 
251
510
  const total = await countQuery.count();
252
- let listQ = query.orderBy(sortCol, order).offset(offset).limit(perPage);
511
+ let listQ = query;
512
+ for (const s of sortEntries) {
513
+ listQ = listQ.orderBy(s.column, s.direction);
514
+ }
515
+ listQ = listQ.offset(offset).limit(perPage);
253
516
  if (include.length > 0) {
254
517
  listQ = listQ.with(...include);
255
518
  }
@@ -276,7 +539,11 @@ function restResourcePlugin(options = {}) {
276
539
  ...chain(async (req, res) => {
277
540
  try {
278
541
  const repo = db.getRepository(model.name);
279
- const include = parseIncludeParam(model, req.query.include);
542
+ const complexity = validateQueryComplexity(model, {
543
+ includes: parseIncludeParam(model, req.query.include),
544
+ strict: false,
545
+ });
546
+ const include = complexity.includes;
280
547
  const record = await repo.findById(req.params.id, { with: include });
281
548
 
282
549
  if (!record) {
@@ -346,6 +613,8 @@ function restResourcePlugin(options = {}) {
346
613
  module.exports = restResourcePlugin;
347
614
  module.exports.pluralizeSegment = pluralizeSegment;
348
615
  module.exports.parseIncludeParam = parseIncludeParam;
616
+ module.exports.parseSortParams = parseSortParams;
617
+ module.exports.applyColumnFilters = applyColumnFilters;
349
618
  module.exports.sanitizeRecordTree = sanitizeRecordTree;
350
619
  module.exports.pickWritableColumns = pickWritableColumns;
351
620
  module.exports.resolveExposedModels = resolveExposedModels;
@@ -126,6 +126,22 @@ function uploadPlugin(options = {}) {
126
126
  version: '1.0.0',
127
127
  description: 'Multipart file uploads with pluggable storage',
128
128
 
129
+ register(ctx) {
130
+ if (ctx.app && ctx.app.serviceRegistry) {
131
+ const { createMediaServices } = require('../../src/services/builtins/media');
132
+ const mediaServices = createMediaServices({
133
+ provider,
134
+ destDir: local?.destDir,
135
+ publicBasePath: local?.publicBasePath,
136
+ });
137
+ for (const [name, def] of Object.entries(mediaServices)) {
138
+ if (!ctx.app.serviceRegistry.has(name)) {
139
+ ctx.app.serviceRegistry.register(name, def);
140
+ }
141
+ }
142
+ }
143
+ },
144
+
129
145
  onRoutesReady(ctx) {
130
146
  const { app } = ctx;
131
147
  app.set('webspresso.uploadPath', normalizedPath);