webspresso 0.0.90 → 0.0.91

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 (94) hide show
  1. package/README.md +358 -8
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/middleware.js +9 -0
  5. package/core/compression/compressible.js +51 -0
  6. package/core/compression/compression-stream.js +59 -0
  7. package/core/compression/index.js +25 -0
  8. package/core/compression/middleware.js +301 -0
  9. package/core/compression/negotiate.js +153 -0
  10. package/core/errors/base.js +31 -0
  11. package/core/errors/domain.js +193 -0
  12. package/core/errors/http.js +133 -0
  13. package/core/errors/index.js +77 -0
  14. package/core/errors/middleware.js +162 -0
  15. package/core/errors/normalize.js +171 -0
  16. package/core/orm/complexity.js +120 -0
  17. package/core/orm/events.js +64 -14
  18. package/core/orm/index.js +45 -10
  19. package/core/orm/model.js +2 -0
  20. package/core/orm/query-builder.js +30 -4
  21. package/core/orm/repository.js +57 -25
  22. package/core/orm/transaction.js +39 -5
  23. package/core/orm/utils.js +6 -3
  24. package/core/queue/adapters/database.js +284 -0
  25. package/core/queue/adapters/memory.js +155 -0
  26. package/core/queue/adapters/redis.js +336 -0
  27. package/core/queue/index.js +21 -0
  28. package/core/queue/job.js +103 -0
  29. package/core/queue/manager.js +387 -0
  30. package/core/realtime/adapters/redis.js +431 -0
  31. package/core/realtime/adapters/socket-io.js +203 -0
  32. package/core/realtime/adapters/sse.js +210 -0
  33. package/core/realtime/adapters/websocket.js +337 -0
  34. package/core/realtime/auth-manager.js +140 -0
  35. package/core/realtime/core.js +359 -0
  36. package/core/realtime/emitter.js +98 -0
  37. package/core/realtime/errors.js +71 -0
  38. package/core/realtime/index.js +79 -0
  39. package/core/realtime/reconnect-manager.js +118 -0
  40. package/core/realtime/registry.js +243 -0
  41. package/core/shutdown/http-adapter.js +134 -0
  42. package/core/shutdown/index.js +12 -0
  43. package/core/shutdown/shutdown-manager.js +312 -0
  44. package/core/ssr/index.js +13 -0
  45. package/core/ssr/stream.js +174 -0
  46. package/index.d.ts +829 -52
  47. package/index.js +103 -0
  48. package/package.json +22 -2
  49. package/plugins/admin-panel/api.js +37 -4
  50. package/plugins/admin-panel/app.js +57 -17
  51. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  52. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  53. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  54. package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
  55. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  56. package/plugins/admin-panel/core/admin-module.js +2 -2
  57. package/plugins/admin-panel/core/api-extensions.js +149 -0
  58. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  59. package/plugins/admin-panel/index.js +90 -17
  60. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  61. package/plugins/admin-panel/modules/menu.js +85 -0
  62. package/plugins/admin-panel/modules/user-management.js +5 -2
  63. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  64. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  65. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  66. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  67. package/plugins/basic-auth/index.js +293 -0
  68. package/plugins/data-exchange/export-xlsx.js +5 -1
  69. package/plugins/data-exchange/import.js +16 -2
  70. package/plugins/data-exchange/index.js +14 -0
  71. package/plugins/email/index.js +14 -1
  72. package/plugins/index.js +29 -0
  73. package/plugins/queue/index.js +108 -0
  74. package/plugins/realtime/index.js +150 -0
  75. package/plugins/recaptcha/index.js +11 -0
  76. package/plugins/redirect/index.js +9 -1
  77. package/plugins/rest-resources/index.js +280 -11
  78. package/plugins/upload/index.js +16 -0
  79. package/src/app-context.js +46 -6
  80. package/src/file-router.js +196 -21
  81. package/src/helpers.js +20 -2
  82. package/src/plugin-manager.js +124 -4
  83. package/src/server.js +304 -44
  84. package/src/services/builtins/auth.js +273 -0
  85. package/src/services/builtins/exchange.js +229 -0
  86. package/src/services/builtins/mail.js +134 -0
  87. package/src/services/builtins/media.js +196 -0
  88. package/src/services/builtins/system.js +221 -0
  89. package/src/services/discovery.js +140 -0
  90. package/src/services/executor.js +292 -0
  91. package/src/services/index.js +70 -0
  92. package/src/services/memoize.js +256 -0
  93. package/src/services/registry.js +288 -0
  94. package/src/services/validator.js +281 -0
@@ -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);
@@ -3,21 +3,23 @@
3
3
  * @module src/app-context
4
4
  */
5
5
 
6
- /** @type {{ db: object|null }} */
6
+ /** @type {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null }} */
7
7
  let context = {
8
8
  db: null,
9
+ shutdownManager: null,
10
+ serviceRegistry: null,
9
11
  };
10
12
 
11
13
  /**
12
14
  * Merge into the current context (typically called once from createApp).
13
- * @param {{ db?: object|null }} partial
15
+ * @param {{ db?: object|null, shutdownManager?: object|null, serviceRegistry?: object|null }} partial
14
16
  */
15
17
  function setAppContext(partial) {
16
18
  context = { ...context, ...partial };
17
19
  }
18
20
 
19
21
  /**
20
- * @returns {{ db: object|null }}
22
+ * @returns {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null }}
21
23
  */
22
24
  function getAppContext() {
23
25
  return context;
@@ -45,23 +47,57 @@ function hasDb() {
45
47
  return context.db != null;
46
48
  }
47
49
 
50
+ /**
51
+ * @returns {object|null}
52
+ */
53
+ function getShutdownManager() {
54
+ return context.shutdownManager;
55
+ }
56
+
57
+ /**
58
+ * @returns {boolean}
59
+ */
60
+ function hasShutdownManager() {
61
+ return context.shutdownManager != null;
62
+ }
63
+
64
+ /**
65
+ * Get the registered ServiceRegistry instance
66
+ * @returns {object|null}
67
+ */
68
+ function getServiceRegistry() {
69
+ return context.serviceRegistry;
70
+ }
71
+
72
+ /**
73
+ * Check if a ServiceRegistry is registered
74
+ * @returns {boolean}
75
+ */
76
+ function hasServiceRegistry() {
77
+ return context.serviceRegistry != null;
78
+ }
79
+
48
80
  /**
49
81
  * Clear context (e.g. between tests).
50
82
  */
51
83
  function resetAppContext() {
52
- context = { db: null };
84
+ context = { db: null, shutdownManager: null, serviceRegistry: null };
53
85
  }
54
86
 
55
87
  /**
56
- * Express middleware: sets `req.db` from registered app context.
88
+ * Express middleware: sets `req.db` and `req.service` from registered app context.
57
89
  * File-based `pages/api/*` routes attach this automatically; use in `setupRoutes`
58
- * for manually registered handlers that need `req.db`.
90
+ * for manually registered handlers that need `req.db` and `req.service`.
59
91
  * @type {import('express').RequestHandler}
60
92
  */
61
93
  function attachDbMiddleware(req, res, next) {
62
94
  if (context.db != null) {
63
95
  req.db = context.db;
64
96
  }
97
+ if (context.serviceRegistry != null) {
98
+ req.service = (name, input, opts) =>
99
+ context.serviceRegistry.call(name, input, req.context || { req, res, db: context.db }, opts);
100
+ }
65
101
  next();
66
102
  }
67
103
 
@@ -70,6 +106,10 @@ module.exports = {
70
106
  getAppContext,
71
107
  getDb,
72
108
  hasDb,
109
+ getShutdownManager,
110
+ hasShutdownManager,
111
+ getServiceRegistry,
112
+ hasServiceRegistry,
73
113
  resetAppContext,
74
114
  attachDbMiddleware,
75
115
  };