webspresso 0.0.98 → 0.0.100

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 (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -21,12 +21,34 @@ function createAuditLogHandlers(options) {
21
21
  if (query.action) {
22
22
  qb.where('action', String(query.action));
23
23
  }
24
+ const recordId = query.recordId || query.resource_id || query.resourceId;
25
+ if (recordId !== undefined && recordId !== null && String(recordId).trim() !== '') {
26
+ qb.where('resource_id', String(recordId));
27
+ }
28
+ const actorId = query.actorId || query.actor_id;
29
+ if (actorId !== undefined && actorId !== null && String(actorId).trim() !== '') {
30
+ qb.where('actor_id', Number(actorId));
31
+ }
32
+ const actorEmail = query.actorEmail || query.actor_email;
33
+ if (actorEmail) {
34
+ qb.where('actor_email', 'like', `%${String(actorEmail).trim()}%`);
35
+ }
24
36
  if (query.from) {
25
37
  qb.where('created_at', '>=', String(query.from));
26
38
  }
27
39
  if (query.to) {
28
40
  qb.where('created_at', '<=', String(query.to));
29
41
  }
42
+ const searchTerm = query.search || query.q;
43
+ if (searchTerm && String(searchTerm).trim() !== '') {
44
+ const term = `%${String(searchTerm).trim()}%`;
45
+ qb.where((sub) => {
46
+ sub.where('actor_email', 'like', term)
47
+ .orWhere('path', 'like', term)
48
+ .orWhere('resource_id', 'like', term)
49
+ .orWhere('resource_model', 'like', term);
50
+ });
51
+ }
30
52
  }
31
53
 
32
54
  async function listHandler(req, res) {
@@ -53,6 +75,79 @@ function createAuditLogHandlers(options) {
53
75
  }
54
76
  }
55
77
 
78
+ /**
79
+ * Dedicated Record Timeline Handler
80
+ * GET /audit-logs/timeline/:model/:id or GET /audit-logs/timeline?model=Post&id=1
81
+ */
82
+ async function timelineHandler(req, res) {
83
+ try {
84
+ const model = req.params?.model || req.query?.model;
85
+ const recordId = req.params?.id || req.query?.id || req.query?.recordId || req.query?.resource_id;
86
+
87
+ if (!model || recordId === undefined) {
88
+ return res.status(400).json({ error: 'model and id are required to fetch timeline' });
89
+ }
90
+
91
+ const rows = await knex(tableName)
92
+ .where({
93
+ resource_model: String(model),
94
+ resource_id: String(recordId),
95
+ })
96
+ .orderBy('created_at', 'asc');
97
+
98
+ const totalChanges = rows.length;
99
+ const createdEvent = rows.find((r) => r.action === 'create') || null;
100
+ const lastModifiedEvent = rows.length > 0 ? rows[rows.length - 1] : null;
101
+
102
+ const events = rows.map((r, index) => {
103
+ let parsedMetadata = r.metadata;
104
+ if (typeof parsedMetadata === 'string') {
105
+ try {
106
+ parsedMetadata = JSON.parse(parsedMetadata);
107
+ } catch {}
108
+ }
109
+
110
+ const changedFields = Array.isArray(parsedMetadata?.changedFields)
111
+ ? parsedMetadata.changedFields
112
+ : [];
113
+
114
+ return {
115
+ id: r.id,
116
+ action: r.action,
117
+ createdAt: r.created_at,
118
+ actor: {
119
+ id: r.actor_id,
120
+ email: r.actor_email,
121
+ },
122
+ resourceModel: r.resource_model,
123
+ resourceId: r.resource_id,
124
+ httpMethod: r.http_method,
125
+ path: r.path,
126
+ ip: r.ip,
127
+ userAgent: r.user_agent,
128
+ changedFields,
129
+ metadata: parsedMetadata,
130
+ index: index + 1,
131
+ };
132
+ });
133
+
134
+ res.json({
135
+ model: String(model),
136
+ recordId: String(recordId),
137
+ summary: {
138
+ totalChanges,
139
+ createdAt: createdEvent?.created_at || (events[0] ? events[0].createdAt : null),
140
+ createdBy: createdEvent?.actor_email || (events[0] ? events[0].actor?.email : null),
141
+ lastModifiedAt: lastModifiedEvent?.created_at || null,
142
+ lastModifiedBy: lastModifiedEvent?.actor_email || null,
143
+ },
144
+ timeline: events,
145
+ });
146
+ } catch (err) {
147
+ res.status(500).json({ error: err.message });
148
+ }
149
+ }
150
+
56
151
  async function purgeHandler(req, res) {
57
152
  try {
58
153
  const body = req.body || {};
@@ -89,8 +184,10 @@ function createAuditLogHandlers(options) {
89
184
  * @param {Object} [filters]
90
185
  * @param {string} [filters.model]
91
186
  * @param {string} [filters.action]
187
+ * @param {string} [filters.recordId]
92
188
  * @param {string} [filters.from]
93
189
  * @param {string} [filters.to]
190
+ * @param {string} [filters.search]
94
191
  * @param {number} [filters.limit=100]
95
192
  * @param {number} [filters.offset=0]
96
193
  */
@@ -105,10 +202,27 @@ function createAuditLogHandlers(options) {
105
202
  .limit(limit);
106
203
  }
107
204
 
205
+ /**
206
+ * Programmatic query for single record timeline
207
+ * @param {string} model
208
+ * @param {string|number} recordId
209
+ */
210
+ async function queryTimeline(model, recordId) {
211
+ if (!model || recordId === undefined) return [];
212
+ return knex(tableName)
213
+ .where({
214
+ resource_model: String(model),
215
+ resource_id: String(recordId),
216
+ })
217
+ .orderBy('created_at', 'asc');
218
+ }
219
+
108
220
  return {
109
221
  listHandler,
222
+ timelineHandler,
110
223
  purgeHandler,
111
224
  queryLogs,
225
+ queryTimeline,
112
226
  };
113
227
  }
114
228
 
@@ -36,6 +36,7 @@ function auditLogPlugin(options = {}) {
36
36
  tableName = 'audit_logs',
37
37
  includeDefaultPage = true,
38
38
  apiPrefix = '/audit-logs',
39
+ registerRecordAction = true,
39
40
  } = options;
40
41
 
41
42
  const pageTitle = title || adminTitle || customPageTitle || 'Audit log';
@@ -152,6 +153,8 @@ function auditLogPlugin(options = {}) {
152
153
  return { ...retentionConfig };
153
154
  },
154
155
  queryLogs: (filters) => handlers.queryLogs(filters),
156
+ queryTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
157
+ getRecordTimeline: (model, recordId) => handlers.queryTimeline(model, recordId),
155
158
  getMigrationTemplate: (name) => generateAuditLogsMigration(name || tableName),
156
159
  },
157
160
 
@@ -211,10 +214,31 @@ function auditLogPlugin(options = {}) {
211
214
  prefix: apiPrefix,
212
215
  routes: [
213
216
  { method: 'get', path: '', handler: handlers.listHandler },
217
+ { method: 'get', path: '/timeline', handler: handlers.timelineHandler },
218
+ { method: 'get', path: '/timeline/:model/:id', handler: handlers.timelineHandler },
214
219
  { method: 'post', path: '/purge', handler: handlers.purgeHandler },
215
220
  ],
216
221
  },
217
222
  });
223
+
224
+ if (registerRecordAction && typeof adminApi.registerAction === 'function') {
225
+ adminApi.registerAction('audit-timeline', {
226
+ id: 'audit-timeline',
227
+ label: 'Timeline',
228
+ icon: 'clock',
229
+ color: 'blue',
230
+ models: '*',
231
+ handler: async (record, model) => {
232
+ const primaryKey = model?.primaryKey || 'id';
233
+ const id = record ? record[primaryKey] : null;
234
+ const modelName = model?.name || (typeof model === 'string' ? model : '');
235
+ return {
236
+ redirect: `/audit-log?model=${encodeURIComponent(modelName)}&recordId=${encodeURIComponent(id)}`,
237
+ message: `Timeline: ${modelName} #${id}`,
238
+ };
239
+ },
240
+ });
241
+ }
218
242
  },
219
243
  };
220
244
  }
@@ -249,54 +249,10 @@
249
249
  return;
250
250
  }
251
251
  if (typeof window.DOMPurify !== 'undefined' && typeof window.DOMPurify.sanitize === 'function') {
252
- el.innerHTML = window.DOMPurify.sanitize(htmlStr);
252
+ el.innerHTML = window.DOMPurify.sanitize(String(htmlStr));
253
253
  return;
254
254
  }
255
- try {
256
- var template = document.createElement('template');
257
- template.innerHTML = htmlStr;
258
- var content = template.content;
259
- var allowedTags = {
260
- p: 1, br: 1, b: 1, i: 1, strong: 1, em: 1, u: 1, s: 1, strike: 1,
261
- h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1,
262
- blockquote: 1, pre: 1, code: 1, a: 1, span: 1, div: 1, hr: 1,
263
- table: 1, thead: 1, tbody: 1, tr: 1, th: 1, td: 1, img: 1
264
- };
265
- var allowedAttrs = {
266
- href: 1, title: 1, target: 1, rel: 1, class: 1, id: 1, src: 1, alt: 1, width: 1, height: 1
267
- };
268
- var allNodes = content.querySelectorAll('*');
269
- for (var i = allNodes.length - 1; i >= 0; i--) {
270
- var node = allNodes[i];
271
- var tag = node.tagName.toLowerCase();
272
- if (!allowedTags[tag]) {
273
- if (node.parentNode) {
274
- node.parentNode.removeChild(node);
275
- } else if (typeof node.remove === 'function') {
276
- node.remove();
277
- }
278
- continue;
279
- }
280
- var attrs = Array.prototype.slice.call(node.attributes);
281
- for (var j = 0; j < attrs.length; j++) {
282
- var attrName = attrs[j].name.toLowerCase();
283
- var attrVal = (attrs[j].value || '').toLowerCase().replace(/[\x00-\x20\s]+/g, '').trim();
284
- if (attrName.indexOf('on') === 0 || !allowedAttrs[attrName]) {
285
- node.removeAttribute(attrs[j].name);
286
- } else if ((attrName === 'href' || attrName === 'src' || attrName === 'action' || attrName === 'formaction' || attrName === 'xlink:href') &&
287
- /^(?:javascript|data|vbscript):/i.test(attrVal)) {
288
- node.removeAttribute(attrs[j].name);
289
- } else if (tag === 'a' && node.getAttribute('target') === '_blank') {
290
- node.setAttribute('rel', 'noopener noreferrer');
291
- }
292
- }
293
- }
294
-
295
- el.textContent = '';
296
- el.appendChild(content.cloneNode(true));
297
- } catch (e) {
298
- el.textContent = htmlStr;
299
- }
255
+ el.textContent = String(htmlStr);
300
256
  }
301
257
 
302
258
  function updateDomFields(entryId, data) {
package/plugins/cors.js CHANGED
@@ -76,7 +76,18 @@ function createCorsMiddleware(options = {}) {
76
76
  return config.origin(reqOrigin, callback);
77
77
  }
78
78
  if (config.origin === true) {
79
- return callback(null, reqOrigin);
79
+ if (!reqOrigin || reqOrigin === 'null') {
80
+ return callback(null, false);
81
+ }
82
+ try {
83
+ const u = new URL(reqOrigin);
84
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
85
+ return callback(null, false);
86
+ }
87
+ return callback(null, u.origin);
88
+ } catch {
89
+ return callback(null, false);
90
+ }
80
91
  }
81
92
  return callback(null, false);
82
93
  }
@@ -101,7 +112,7 @@ function createCorsMiddleware(options = {}) {
101
112
  return next(err);
102
113
  }
103
114
 
104
- if (allowedOrigin && allowedOrigin !== '*') {
115
+ if (allowedOrigin && allowedOrigin !== '*' && allowedOrigin !== 'null') {
105
116
  res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
106
117
  res.setHeader('Vary', 'Origin');
107
118
  if (config.credentials) {
@@ -26,6 +26,13 @@ const DEFAULT_AUTH_EMAILS = {
26
26
  template: 'auth-welcome',
27
27
  subject: 'Welcome',
28
28
  },
29
+ magicLink: {
30
+ enabled: true,
31
+ template: 'auth-magic-link',
32
+ subject: 'Your sign-in link',
33
+ path: '/auth/magic-link/verify',
34
+ tokenTtlMs: 15 * 60 * 1000,
35
+ },
29
36
  registerRoutes: true,
30
37
  routePrefix: '/api/auth',
31
38
  };
@@ -46,6 +53,7 @@ function wireAuthEmails(authManager, emailService, registry, config, db) {
46
53
  passwordReset: { ...DEFAULT_AUTH_EMAILS.passwordReset, ...config.passwordReset },
47
54
  emailVerification: { ...DEFAULT_AUTH_EMAILS.emailVerification, ...config.emailVerification },
48
55
  welcome: { ...DEFAULT_AUTH_EMAILS.welcome, ...config.welcome },
56
+ magicLink: { ...DEFAULT_AUTH_EMAILS.magicLink, ...config.magicLink },
49
57
  };
50
58
 
51
59
  registerBundledAuthTemplates(registry);
@@ -111,6 +119,23 @@ function wireAuthEmails(authManager, emailService, registry, config, db) {
111
119
  };
112
120
  }
113
121
 
122
+ if (merged.magicLink?.enabled) {
123
+ authManager.notifications.magicLink = async (user, { token, magicLinkUrl, expiresMinutes }) => {
124
+ const url = magicLinkUrl || buildUrl(merged.magicLink.path, token);
125
+ await emailService.sendTemplate(merged.magicLink.template, {
126
+ to: user[identifierField],
127
+ subject: merged.magicLink.subject,
128
+ data: {
129
+ name: userDisplayName(user),
130
+ token,
131
+ magicLinkUrl: url,
132
+ expiresMinutes: expiresMinutes || 15,
133
+ email: user[identifierField],
134
+ },
135
+ });
136
+ };
137
+ }
138
+
114
139
  return merged;
115
140
  }
116
141
 
@@ -193,6 +218,41 @@ function registerAuthEmailRoutes(options) {
193
218
  }
194
219
  }
195
220
 
221
+ async function magicLinkRequestHandler(req, res) {
222
+ try {
223
+ const email = req.body?.email || req.body?.identifier;
224
+ if (email) {
225
+ await authManager.requestMagicLink(String(email), {
226
+ tokenTtlMs: authEmailsConfig.magicLink?.tokenTtlMs,
227
+ path: authEmailsConfig.magicLink?.path,
228
+ buildMagicLinkUrl: (token) => {
229
+ const pathSeg = authEmailsConfig.magicLink?.path || '/auth/magic-link/verify';
230
+ return `${base}${pathSeg}?token=${encodeURIComponent(token)}`;
231
+ },
232
+ });
233
+ }
234
+ res.json({ ok: true, message: 'If the account exists, a sign-in link has been sent.' });
235
+ } catch (err) {
236
+ res.status(500).json({ error: err.message });
237
+ }
238
+ }
239
+
240
+ async function magicLinkVerifyHandler(req, res) {
241
+ try {
242
+ const token = req.body?.token || req.query?.token;
243
+ if (!token) {
244
+ return res.status(400).json({ error: 'token is required' });
245
+ }
246
+ const { user } = await authManager.verifyMagicLink(String(token));
247
+ if (req.auth && typeof req.auth.login === 'function') {
248
+ await req.auth.login(user);
249
+ }
250
+ res.json({ ok: true, user });
251
+ } catch (err) {
252
+ res.status(err.status || 400).json({ error: err.message, code: err.code });
253
+ }
254
+ }
255
+
196
256
  const forgotMw = guest ? [guest, forgotPasswordHandler] : [forgotPasswordHandler];
197
257
  const resetMw = guest ? [guest, resetPasswordHandler] : [resetPasswordHandler];
198
258
  const resendMw = authMw ? [authMw, resendVerificationHandler] : [resendVerificationHandler];
@@ -201,6 +261,12 @@ function registerAuthEmailRoutes(options) {
201
261
  ctx.addRoute('post', `${prefix}/reset-password`, ...resetMw);
202
262
  ctx.addRoute('post', `${prefix}/verify-email`, verifyEmailHandler);
203
263
  ctx.addRoute('post', `${prefix}/resend-verification`, ...resendMw);
264
+
265
+ if (authEmailsConfig.magicLink?.enabled) {
266
+ const magicMw = guest ? [guest, magicLinkRequestHandler] : [magicLinkRequestHandler];
267
+ ctx.addRoute('post', `${prefix}/magic-link`, ...magicMw);
268
+ ctx.addRoute('post', `${prefix}/magic-link/verify`, magicLinkVerifyHandler);
269
+ }
204
270
  }
205
271
 
206
272
  module.exports = {
@@ -47,6 +47,21 @@ const BUNDLED_AUTH_TEMPLATES = {
47
47
  </mj-column>
48
48
  </mj-section>
49
49
  </mj-body>
50
+ </mjml>`,
51
+ },
52
+ 'auth-magic-link': {
53
+ mjml: `<mjml>
54
+ <mj-body background-color="#f4f4f5">
55
+ <mj-section background-color="#ffffff" padding="32px 24px">
56
+ <mj-column>
57
+ <mj-text font-size="20px" font-weight="600" color="#18181b">Your sign-in link</mj-text>
58
+ <mj-text color="#52525b">Hi {{name}},</mj-text>
59
+ <mj-text color="#52525b">Click the button below to securely sign in to your account. This link will expire in {{expiresMinutes}} minutes.</mj-text>
60
+ <mj-button background-color="#4f46e5" href="{{magicLinkUrl}}">Sign In</mj-button>
61
+ <mj-text color="#71717a" font-size="12px">If you did not request this link, you can safely ignore this email.</mj-text>
62
+ </mj-column>
63
+ </mj-section>
64
+ </mj-body>
50
65
  </mjml>`,
51
66
  },
52
67
  };
@@ -0,0 +1,13 @@
1
+ <mjml>
2
+ <mj-body background-color="#f4f4f5">
3
+ <mj-section background-color="#ffffff" padding="32px 24px">
4
+ <mj-column>
5
+ <mj-text font-size="20px" font-weight="600" color="#18181b">Your sign-in link</mj-text>
6
+ <mj-text color="#52525b">Hi {{name}},</mj-text>
7
+ <mj-text color="#52525b">Click the button below to securely sign in to your account. This link will expire in {{expiresMinutes}} minutes.</mj-text>
8
+ <mj-button background-color="#4f46e5" href="{{magicLinkUrl}}">Sign In</mj-button>
9
+ <mj-text color="#71717a" font-size="12px">If you did not request this link, you can safely ignore this email.</mj-text>
10
+ </mj-column>
11
+ </mj-section>
12
+ </mj-body>
13
+ </mjml>