webspresso 0.0.97 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -43,6 +43,8 @@ function adminPanelPlugin(options = {}) {
43
43
  const {
44
44
  path: adminPath = '/_admin',
45
45
  enabled = true,
46
+ title,
47
+ titleTemplate,
46
48
  sessionSecret,
47
49
  db,
48
50
  auth,
@@ -52,6 +54,7 @@ function adminPanelPlugin(options = {}) {
52
54
  uploadUrl: uploadUrlOption,
53
55
  richTextSanitize = true,
54
56
  dataExchange: dataExchangeOption = true,
57
+ loginRateLimit,
55
58
  } = options;
56
59
 
57
60
  const adminUsersConfig = typeof adminUsersOption === 'object' ? adminUsersOption : { enabled: adminUsersOption !== false };
@@ -63,6 +66,12 @@ function adminPanelPlugin(options = {}) {
63
66
 
64
67
  // Create fresh registry for this plugin instance
65
68
  const registry = new AdminRegistry();
69
+ if (title) {
70
+ registry.setTitle(title);
71
+ }
72
+ if (titleTemplate) {
73
+ registry.setTitleTemplate(titleTemplate);
74
+ }
66
75
 
67
76
  // Dependencies are now in main package
68
77
  const session = require('express-session');
@@ -144,8 +153,40 @@ function adminPanelPlugin(options = {}) {
144
153
  registerStyle(style) {
145
154
  return registry.registerStyle(style);
146
155
  },
156
+ registerHook(hookName, callback) {
157
+ return registry.registerHook(hookName, callback);
158
+ },
159
+ runHooks(hookName, context) {
160
+ return registry.runHooks(hookName, context);
161
+ },
162
+ registerAction(id, config) {
163
+ return registry.registerAction(id, config);
164
+ },
165
+ registerBulkAction(id, config) {
166
+ return registry.registerBulkAction(id, config);
167
+ },
168
+ setTitle(title) {
169
+ registry.setTitle(title);
170
+ return this;
171
+ },
172
+ getTitle() {
173
+ return registry.getTitle();
174
+ },
175
+ setTitleTemplate(template) {
176
+ registry.setTitleTemplate(template);
177
+ return this;
178
+ },
179
+ getTitleTemplate() {
180
+ return registry.getTitleTemplate();
181
+ },
182
+ resetLoginAttempts(ip) {
183
+ if (this._apiHandlers && typeof this._apiHandlers.resetLoginAttempts === 'function') {
184
+ return this._apiHandlers.resetLoginAttempts(ip);
185
+ }
186
+ },
147
187
 
148
188
  _ctx: null,
189
+ _apiHandlers: null,
149
190
  },
150
191
 
151
192
  /**
@@ -275,15 +316,68 @@ function adminPanelPlugin(options = {}) {
275
316
  });
276
317
  }
277
318
 
319
+ // Auto-register model-level declared actions and bulkActions
320
+ const { getAllModels: getFrameworkModels } = require('../../core/orm/model');
321
+ const modelMap = new Map();
322
+ if (getFrameworkModels) {
323
+ for (const [name, model] of getFrameworkModels()) {
324
+ modelMap.set(name, model);
325
+ }
326
+ }
327
+ if (db.getAllModels) {
328
+ const dbModels = db.getAllModels();
329
+ const list = Array.isArray(dbModels) ? dbModels : Array.from(dbModels.values());
330
+ for (const model of list) {
331
+ modelMap.set(model.name, model);
332
+ }
333
+ }
334
+ const appModelList = Array.from(modelMap.values());
335
+ for (const model of appModelList) {
336
+ if (model.admin?.actions && Array.isArray(model.admin.actions)) {
337
+ for (const act of model.admin.actions) {
338
+ if (act.id && act.label && act.handler && !registry.actions.has(act.id)) {
339
+ registry.registerAction(act.id, {
340
+ models: [model.name],
341
+ ...act,
342
+ });
343
+ }
344
+ }
345
+ }
346
+ if (model.admin?.bulkActions && Array.isArray(model.admin.bulkActions)) {
347
+ for (const bAct of model.admin.bulkActions) {
348
+ if (bAct.id && bAct.label && bAct.handler && !registry.bulkActions.has(bAct.id)) {
349
+ registry.registerBulkAction(bAct.id, {
350
+ models: [model.name],
351
+ ...bAct,
352
+ });
353
+ }
354
+ }
355
+ }
356
+ if (model.admin?.hooks && typeof model.admin.hooks === 'object') {
357
+ for (const [hookName, fn] of Object.entries(model.admin.hooks)) {
358
+ if (typeof fn === 'function') {
359
+ registry.registerHook(hookName, async (ctx) => {
360
+ if (!ctx.model || ctx.model === model.name) {
361
+ await fn(ctx);
362
+ }
363
+ });
364
+ }
365
+ }
366
+ }
367
+ }
368
+
278
369
  // Create API handlers
279
370
  const apiHandlers = createApiHandlers({
280
371
  path: adminPath,
281
372
  db,
373
+ registry,
282
374
  AdminUser,
283
375
  hashPassword: (password, rounds) => bcrypt.hash(password, process.env.NODE_ENV === 'test' ? 4 : (rounds || 10)),
284
376
  comparePassword: (password, hash) => bcrypt.compare(password, hash),
285
377
  richTextSanitize,
378
+ loginRateLimit,
286
379
  });
380
+ this.api._apiHandlers = apiHandlers;
287
381
 
288
382
  const extensionHandlers = createExtensionApiHandlers({
289
383
  registry,
@@ -330,7 +424,9 @@ function adminPanelPlugin(options = {}) {
330
424
  ctx.addRoute('get', `${adminPath}/api/extensions/widgets/:widgetId/data`, requireAuth, extensionHandlers.widgetDataHandler);
331
425
  ctx.addRoute('get', `${adminPath}/api/extensions/dashboard/stats`, requireAuth, extensionHandlers.dashboardStatsHandler);
332
426
  ctx.addRoute('post', `${adminPath}/api/extensions/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
427
+ ctx.addRoute('post', `${adminPath}/api/actions/:actionId/:model/:id`, requireAuth, extensionHandlers.actionHandler);
333
428
  ctx.addRoute('post', `${adminPath}/api/extensions/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
429
+ ctx.addRoute('post', `${adminPath}/api/bulk-actions/:actionId/:model`, requireAuth, extensionHandlers.bulkActionHandler);
334
430
  ctx.addRoute('get', `${adminPath}/api/extensions/bulk-fields/:model`, requireAuth, extensionHandlers.bulkFieldsHandler);
335
431
  ctx.addRoute('post', `${adminPath}/api/extensions/bulk-update/:model`, requireAuth, extensionHandlers.bulkUpdateFieldHandler);
336
432
  ctx.addRoute('get', `${adminPath}/api/extensions/export/:model`, requireAuth, extensionHandlers.exportHandler);
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * In-memory brute-force rate limiter for admin login.
5
+ * Zero external dependencies.
6
+ */
7
+ class LoginRateLimiter {
8
+ constructor(options = {}) {
9
+ if (options === false) {
10
+ this.enabled = false;
11
+ this.maxAttempts = 5;
12
+ this.windowMs = 15 * 60 * 1000;
13
+ } else {
14
+ const opts = typeof options === 'object' && options !== null ? options : {};
15
+ this.enabled = opts.enabled !== false;
16
+ this.maxAttempts = typeof opts.maxAttempts === 'number' && opts.maxAttempts > 0 ? opts.maxAttempts : 5;
17
+ this.windowMs = typeof opts.windowMs === 'number' && opts.windowMs > 0 ? opts.windowMs : (15 * 60 * 1000);
18
+ }
19
+ this.attempts = new Map();
20
+ }
21
+
22
+ getClientIp(req) {
23
+ if (!req) return '127.0.0.1';
24
+ if (req.ip) return String(req.ip);
25
+ if (req.headers && req.headers['x-forwarded-for']) {
26
+ const forwarded = String(req.headers['x-forwarded-for']).split(',')[0].trim();
27
+ if (forwarded) return forwarded;
28
+ }
29
+ if (req.connection && req.connection.remoteAddress) {
30
+ return String(req.connection.remoteAddress);
31
+ }
32
+ if (req.socket && req.socket.remoteAddress) {
33
+ return String(req.socket.remoteAddress);
34
+ }
35
+ return '127.0.0.1';
36
+ }
37
+
38
+ isBlocked(req) {
39
+ if (!this.enabled) return { blocked: false, remaining: Infinity, count: 0 };
40
+ const ip = this.getClientIp(req);
41
+ const now = Date.now();
42
+ const entry = this.attempts.get(ip);
43
+ if (!entry) return { blocked: false, remaining: this.maxAttempts, count: 0 };
44
+
45
+ if (now >= entry.resetAt) {
46
+ this.attempts.delete(ip);
47
+ return { blocked: false, remaining: this.maxAttempts, count: 0 };
48
+ }
49
+
50
+ if (entry.count >= this.maxAttempts) {
51
+ const remainingMs = Math.max(0, entry.resetAt - now);
52
+ const waitMinutes = Math.max(1, Math.ceil(remainingMs / 60000));
53
+ const retryAfter = Math.max(1, Math.ceil(remainingMs / 1000));
54
+ return {
55
+ blocked: true,
56
+ remaining: 0,
57
+ count: entry.count,
58
+ waitMinutes,
59
+ retryAfter,
60
+ message: `Too many failed login attempts. Please try again in ${waitMinutes} ${waitMinutes === 1 ? 'minute' : 'minutes'}.`,
61
+ };
62
+ }
63
+
64
+ return {
65
+ blocked: false,
66
+ remaining: Math.max(0, this.maxAttempts - entry.count),
67
+ count: entry.count,
68
+ };
69
+ }
70
+
71
+ recordFailure(req) {
72
+ if (!this.enabled) return { blocked: false, remaining: Infinity, count: 0 };
73
+ const ip = this.getClientIp(req);
74
+ const now = Date.now();
75
+ const entry = this.attempts.get(ip);
76
+
77
+ if (!entry || now >= entry.resetAt) {
78
+ this.attempts.set(ip, {
79
+ count: 1,
80
+ resetAt: now + this.windowMs,
81
+ });
82
+ } else {
83
+ entry.count += 1;
84
+ }
85
+
86
+ if (this.attempts.size > 500) {
87
+ this.cleanup();
88
+ }
89
+
90
+ return this.isBlocked(req);
91
+ }
92
+
93
+ recordSuccess(req) {
94
+ if (!this.enabled) return;
95
+ const ip = this.getClientIp(req);
96
+ this.attempts.delete(ip);
97
+ }
98
+
99
+ reset(ip) {
100
+ if (ip) {
101
+ this.attempts.delete(ip);
102
+ } else {
103
+ this.attempts.clear();
104
+ }
105
+ }
106
+
107
+ cleanup() {
108
+ const now = Date.now();
109
+ for (const [ip, entry] of this.attempts.entries()) {
110
+ if (now >= entry.resetAt) {
111
+ this.attempts.delete(ip);
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ module.exports = {
118
+ LoginRateLimiter,
119
+ };
@@ -497,7 +497,16 @@ const AdminUsersPage = {
497
497
  };
498
498
 
499
499
  vnode.state.deleteAdmin = async function(admin) {
500
- if (!confirm('Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.')) {
500
+ var confirmed = typeof confirmAction === 'function'
501
+ ? await confirmAction({
502
+ title: 'Delete Administrator',
503
+ message: 'Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.',
504
+ danger: true,
505
+ confirmText: 'Delete'
506
+ })
507
+ : confirm('Are you sure you want to delete administrator "' + (admin.name || admin.email) + '"? This action cannot be undone.');
508
+
509
+ if (!confirmed) {
501
510
  return;
502
511
  }
503
512
  vnode.state.loading = true;
@@ -506,9 +515,15 @@ const AdminUsersPage = {
506
515
  try {
507
516
  await api.delete('/admins/' + encodeURIComponent(admin.id));
508
517
  vnode.state.success = 'Administrator deleted successfully';
518
+ if (typeof notifySuccess === 'function') {
519
+ notifySuccess('Administrator deleted successfully');
520
+ }
509
521
  await vnode.state.loadAdmins();
510
522
  } catch (err) {
511
523
  vnode.state.error = err.message || 'Failed to delete administrator';
524
+ if (typeof notifyError === 'function') {
525
+ notifyError(vnode.state.error);
526
+ }
512
527
  vnode.state.loading = false;
513
528
  m.redraw();
514
529
  }
@@ -22,12 +22,15 @@ function registerDefaultBulkActions(options) {
22
22
  confirm: true,
23
23
  confirmMessage: 'Restore the selected records?',
24
24
  handler: async (records, modelName, { db }) => {
25
+ const targetModel = typeof modelName === 'object' && modelName !== null
26
+ ? (modelName.name || modelName.modelName || modelName.model || String(modelName))
27
+ : String(modelName);
25
28
  const { getModel } = require('../../../core/orm/model');
26
- const model = db.getModel ? db.getModel(modelName) : getModel(modelName);
29
+ const model = db.getModel ? db.getModel(targetModel) : getModel(targetModel);
27
30
  if (!model?.scopes?.softDelete) {
28
31
  return { message: 'Model does not support restore', error: true };
29
32
  }
30
- const repo = db.getRepository(modelName);
33
+ const repo = db.getRepository(targetModel);
31
34
  let restored = 0;
32
35
  for (const record of records) {
33
36
  try {
@@ -48,9 +51,11 @@ function registerDefaultBulkActions(options) {
48
51
  color: 'red',
49
52
  models: '*',
50
53
  confirm: true,
51
- confirmMessage: 'Are you sure you want to delete the selected records? This action cannot be undone.',
52
54
  handler: async (records, model, { db }) => {
53
- const repo = db.getRepository(model);
55
+ const targetModel = typeof model === 'object' && model !== null
56
+ ? (model.name || model.modelName || model.model || String(model))
57
+ : String(model);
58
+ const repo = db.getRepository(targetModel);
54
59
  let deleted = 0;
55
60
 
56
61
  for (const record of records) {
@@ -131,7 +136,10 @@ function createFieldUpdateBulkAction(modelName, field, value, options = {}) {
131
136
  confirm,
132
137
  confirmMessage,
133
138
  handler: async (records, model, { db }) => {
134
- const repo = db.getRepository(model);
139
+ const targetModel = typeof model === 'object' && model !== null
140
+ ? (model.name || model.modelName || model.model || String(model))
141
+ : String(model);
142
+ const repo = db.getRepository(targetModel);
135
143
  let updated = 0;
136
144
 
137
145
  for (const record of records) {