webspresso 0.0.80 → 0.0.81

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.
package/index.js CHANGED
@@ -63,6 +63,7 @@ const {
63
63
  redirectPlugin,
64
64
  rateLimitPlugin,
65
65
  contentPlugin,
66
+ csrfPlugin,
66
67
  } = require('./plugins');
67
68
 
68
69
  const content = require('./core/content');
@@ -133,6 +134,7 @@ module.exports = {
133
134
  redirectPlugin,
134
135
  rateLimitPlugin,
135
136
  contentPlugin,
137
+ csrfPlugin,
136
138
 
137
139
  // Schema-driven CMS core (framework-agnostic)
138
140
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "webspresso",
3
- "version": "0.0.80",
3
+ "version": "0.0.81",
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",
@@ -67,7 +67,9 @@
67
67
  "helmet": "^7.2.0",
68
68
  "inquirer": "^8.2.6",
69
69
  "knex": "^3.1.0",
70
+ "mjml": "^5.4.0",
70
71
  "multer": "^2.1.1",
72
+ "nodemailer": "^9.0.3",
71
73
  "nunjucks": "^3.2.4",
72
74
  "sanitize-html": "^2.17.3",
73
75
  "sharp": "^0.33.5",
@@ -125,6 +127,7 @@
125
127
  "overrides": {
126
128
  "basic-ftp": "^5.3.0",
127
129
  "tar": "^7.5.11",
128
- "undici": "^6.24.0"
130
+ "undici": "^6.24.0",
131
+ "uuid": "^11.1.1"
129
132
  }
130
133
  }
@@ -223,6 +223,8 @@ module.exports = {
223
223
  FilesUploadField: {
224
224
  oninit: (vnode) => {
225
225
  vnode.state.uploading = false;
226
+ vnode.state.draggedIndex = null;
227
+ vnode.state.dragOverIndex = null;
226
228
  },
227
229
  oncreate: (vnode) => {
228
230
  const { name, meta = {} } = vnode.attrs;
@@ -298,19 +300,63 @@ module.exports = {
298
300
 
299
301
  urls.length > 0
300
302
  ? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
301
- return m('.relative.group.border.border-gray-200.dark:border-slate-700.rounded-lg.p-2.bg-gray-50.dark:bg-slate-900', [
303
+ const isDragOver = state.dragOverIndex === idx;
304
+ const baseClasses = '.relative.group.border.rounded-lg.p-2.transition-all';
305
+ const cursorClass = readonly ? '' : '.cursor-move';
306
+ const borderClasses = isDragOver ? '.border-blue-500.border-2.dark:border-blue-500' : '.border-gray-200.dark:border-slate-700';
307
+ const bgClasses = isDragOver ? '.bg-blue-50.dark:bg-slate-800' : '.bg-gray-50.dark:bg-slate-900';
308
+
309
+ return m(baseClasses + cursorClass + borderClasses + bgClasses, {
310
+ draggable: !readonly,
311
+ ondragstart: (e) => {
312
+ state.draggedIndex = idx;
313
+ e.dataTransfer.effectAllowed = 'move';
314
+ e.dataTransfer.setData('text/plain', idx);
315
+ },
316
+ ondragover: (e) => {
317
+ e.preventDefault();
318
+ e.dataTransfer.dropEffect = 'move';
319
+ },
320
+ ondragenter: (e) => {
321
+ state.dragOverIndex = idx;
322
+ },
323
+ ondragleave: (e) => {
324
+ if (state.dragOverIndex === idx) {
325
+ state.dragOverIndex = null;
326
+ }
327
+ },
328
+ ondrop: (e) => {
329
+ e.preventDefault();
330
+ const fromIdx = state.draggedIndex;
331
+ const toIdx = idx;
332
+ if (fromIdx !== null && fromIdx !== undefined && fromIdx !== toIdx) {
333
+ const newUrls = [...urls];
334
+ const [movedItem] = newUrls.splice(fromIdx, 1);
335
+ newUrls.splice(toIdx, 0, movedItem);
336
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
337
+ }
338
+ state.draggedIndex = null;
339
+ state.dragOverIndex = null;
340
+ },
341
+ ondragend: (e) => {
342
+ state.draggedIndex = null;
343
+ state.dragOverIndex = null;
344
+ }
345
+ }, [
302
346
  shouldShowImagePreview(url, accept)
303
- ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer' },
347
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
304
348
  m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
305
349
  src: url,
306
350
  alt: `${label} ${idx + 1}`,
307
351
  loading: 'lazy',
352
+ draggable: false,
308
353
  })
309
354
  )
310
355
  : m('a.flex.items-center.justify-center.h-32.w-full.text-sm.text-indigo-600.dark:text-indigo-400.break-all.p-2', {
311
356
  href: url,
312
357
  target: '_blank',
313
358
  rel: 'noopener noreferrer',
359
+ draggable: false,
314
360
  }, url.substring(url.lastIndexOf('/') + 1) || url),
315
361
  !readonly
316
362
  ? m('button.absolute.top-1.right-1.bg-red-500.text-white.rounded-full.p-1.shadow-md.opacity-0.group-hover:opacity-100.hover:bg-red-700.transition-opacity', {
@@ -0,0 +1,222 @@
1
+ /**
2
+ * CSRF protection plugin — session/cookie double-submit CSRF validation + Nunjucks helpers
3
+ * @module plugins/csrf
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+ const { AsyncLocalStorage } = require('async_hooks');
8
+ const cookieParser = require('cookie-parser');
9
+ const nunjucks = require('nunjucks');
10
+
11
+ // Storage to pass the active request to Nunjucks template helpers in a thread-safe manner
12
+ const csrfStorage = new AsyncLocalStorage();
13
+
14
+ /**
15
+ * Compare two strings in constant time to prevent timing attacks
16
+ * @param {string} a
17
+ * @param {string} b
18
+ * @returns {boolean}
19
+ */
20
+ function safeCompare(a, b) {
21
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
22
+ if (a.length !== b.length) return false;
23
+ try {
24
+ return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
25
+ } catch (err) {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Check if the request path matches any ignore patterns
32
+ * @param {import('express').Request} req
33
+ * @param {Array<string|RegExp|Function>} ignorePaths
34
+ * @returns {boolean}
35
+ */
36
+ function isIgnored(req, ignorePaths) {
37
+ if (!ignorePaths || !Array.isArray(ignorePaths)) return false;
38
+ const path = req.path || '';
39
+
40
+ for (const pattern of ignorePaths) {
41
+ if (typeof pattern === 'string') {
42
+ // Prefix matching
43
+ if (path.startsWith(pattern)) return true;
44
+ // Basic glob matching
45
+ if (pattern.includes('*')) {
46
+ const regexPattern = pattern
47
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
48
+ .replace(/\*\*/g, '.*')
49
+ .replace(/\*/g, '[^/]*');
50
+ if (new RegExp(`^${regexPattern}$`).test(path)) return true;
51
+ }
52
+ } else if (pattern instanceof RegExp) {
53
+ if (pattern.test(path)) return true;
54
+ } else if (typeof pattern === 'function') {
55
+ if (pattern(req)) return true;
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ /**
62
+ * Nunjucks Helper: Get raw CSRF token
63
+ * @returns {string}
64
+ */
65
+ function csrfToken() {
66
+ const req = csrfStorage.getStore();
67
+ if (req && typeof req.csrfToken === 'function') {
68
+ return req.csrfToken();
69
+ }
70
+ return '';
71
+ }
72
+
73
+ /**
74
+ * Nunjucks Helper: Get hidden input HTML element with CSRF token
75
+ * @returns {nunjucks.runtime.SafeString}
76
+ */
77
+ function csrfInput() {
78
+ const token = csrfToken();
79
+ if (!token) return '';
80
+ return new nunjucks.runtime.SafeString(
81
+ `<input type="hidden" name="_csrf" value="${token}">`
82
+ );
83
+ }
84
+
85
+ /**
86
+ * CSRF Plugin
87
+ * @param {Object} [options]
88
+ * @param {boolean|Object} [options.cookie=false] - Cookie options. If true or object, cookie-based storage is used.
89
+ * @param {string} [options.cookie.key='_csrf'] - Cookie name.
90
+ * @param {boolean} [options.cookie.signed=false] - Whether to sign the cookie.
91
+ * @param {string} [options.cookie.path='/'] - Cookie path.
92
+ * @param {boolean} [options.cookie.httpOnly=true] - Cookie httpOnly flag.
93
+ * @param {boolean} [options.cookie.secure] - Cookie secure flag.
94
+ * @param {'lax'|'strict'|'none'} [options.cookie.sameSite='lax'] - Cookie SameSite policy.
95
+ * @param {number} [options.cookie.maxAge] - Cookie maxAge.
96
+ * @param {string} [options.sessionKey='csrfToken'] - Session key name.
97
+ * @param {string[]} [options.methods=['POST', 'PUT', 'DELETE', 'PATCH']] - Methods to protect.
98
+ * @param {Array<string|RegExp|Function>} [options.ignorePaths=[]] - Routes to skip verification.
99
+ * @param {boolean} [options.global=false] - Mount CSRF middleware globally on all routes.
100
+ * @param {string} [options.errorMessage='Invalid or missing CSRF token'] - Error message on failure.
101
+ * @param {number} [options.errorStatus=403] - HTTP status code on failure.
102
+ */
103
+ function csrfPlugin(options = {}) {
104
+ const {
105
+ cookie = false,
106
+ sessionKey = 'csrfToken',
107
+ methods = ['POST', 'PUT', 'DELETE', 'PATCH'],
108
+ ignorePaths = [],
109
+ global: applyGlobal = false,
110
+ errorMessage = 'Invalid or missing CSRF token',
111
+ errorStatus = 403,
112
+ } = options;
113
+
114
+ // Resolve cookie config
115
+ const cookieEnabled = !!cookie;
116
+ const cookieOpts = typeof cookie === 'object' ? cookie : {};
117
+ const cookieKey = cookieOpts.key || '_csrf';
118
+ const cookieOptions = {
119
+ httpOnly: cookieOpts.httpOnly !== undefined ? cookieOpts.httpOnly : true,
120
+ path: cookieOpts.path || '/',
121
+ secure: cookieOpts.secure !== undefined ? cookieOpts.secure : (process.env.NODE_ENV === 'production'),
122
+ sameSite: cookieOpts.sameSite || 'lax',
123
+ signed: !!cookieOpts.signed,
124
+ };
125
+ if (cookieOpts.maxAge !== undefined) {
126
+ cookieOptions.maxAge = cookieOpts.maxAge;
127
+ }
128
+
129
+ /**
130
+ * Internal middleware creator
131
+ * @param {Object} [overrideOpts]
132
+ */
133
+ function createMiddleware(overrideOpts = {}) {
134
+ const activeMethods = overrideOpts.methods || methods;
135
+ const activeIgnorePaths = overrideOpts.ignorePaths || ignorePaths;
136
+
137
+ return (req, res, next) => {
138
+ // Determine if cookie or session storage should be used
139
+ const useCookie = cookieEnabled || !req.session;
140
+
141
+ // Initialize cookie parser dynamically if needed
142
+ if (useCookie && !req.cookies) {
143
+ const secret = cookieOptions.signed ? (cookieOpts.secret || process.env.SESSION_SECRET) : undefined;
144
+ cookieParser(secret)(req, res, () => {});
145
+ }
146
+
147
+ // 1. Retrieve or generate secret token
148
+ let secret;
149
+ if (useCookie) {
150
+ secret = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
151
+ if (!secret) {
152
+ secret = crypto.randomBytes(32).toString('hex');
153
+ res.cookie(cookieKey, secret, cookieOptions);
154
+ }
155
+ } else {
156
+ // Session based
157
+ if (!req.session[sessionKey]) {
158
+ req.session[sessionKey] = crypto.randomBytes(32).toString('hex');
159
+ }
160
+ secret = req.session[sessionKey];
161
+ }
162
+
163
+ // Define standard req.csrfToken()
164
+ req.csrfToken = () => secret;
165
+
166
+ // 2. Perform CSRF verification for mutating HTTP methods
167
+ const method = req.method ? req.method.toUpperCase() : 'GET';
168
+ if (activeMethods.includes(method) && !isIgnored(req, activeIgnorePaths)) {
169
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
170
+ const query = req.query && typeof req.query === 'object' ? req.query : {};
171
+ const submitted = (
172
+ body._csrf ||
173
+ query._csrf ||
174
+ req.headers['x-csrf-token'] ||
175
+ req.headers['xsrf-token'] ||
176
+ req.headers['x-xsrf-token']
177
+ );
178
+
179
+ if (!submitted || !safeCompare(submitted, secret)) {
180
+ res.status(errorStatus);
181
+ if (req.accepts('html') && !req.path.startsWith('/api')) {
182
+ return res.send(`<h1>${errorStatus} Forbidden</h1><p>${errorMessage}</p>`);
183
+ } else {
184
+ return res.json({ error: errorMessage });
185
+ }
186
+ }
187
+ }
188
+
189
+ // 3. Run downstream handlers inside AsyncLocalStorage context
190
+ csrfStorage.run(req, () => {
191
+ next();
192
+ });
193
+ };
194
+ }
195
+
196
+ return {
197
+ name: 'csrf',
198
+ version: '1.0.0',
199
+ description: 'CSRF protection plugin for Webspresso with cookie/session storage and template helpers',
200
+
201
+ api: {
202
+ createMiddleware,
203
+ },
204
+
205
+ register(ctx) {
206
+ // Register named route middleware
207
+ ctx.middlewares.csrf = (routeOpts = {}) => createMiddleware(routeOpts);
208
+
209
+ // Register template helpers
210
+ ctx.addHelper('csrfToken', csrfToken);
211
+ ctx.addHelper('csrfInput', csrfInput);
212
+
213
+ // If global validation is enabled, mount the middleware globally on the Express app
214
+ if (applyGlobal) {
215
+ ctx.app.use(createMiddleware());
216
+ }
217
+ },
218
+ };
219
+ }
220
+
221
+ module.exports = csrfPlugin;
222
+ module.exports.csrfPlugin = csrfPlugin;
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Email admin page — Mithril component source (string)
3
+ * @module plugins/email/admin-component
4
+ */
5
+
6
+ /**
7
+ * @param {Object} [options={}]
8
+ * @param {string} [options.apiPrefix='/email']
9
+ */
10
+ function generateEmailComponent(options = {}) {
11
+ const apiPrefix = options.apiPrefix || '/email';
12
+
13
+ return `
14
+ (function() {
15
+ var API = '${apiPrefix}';
16
+
17
+ function EmailPage() {
18
+ var tab = 'logs';
19
+ var logs = [];
20
+ var templates = [];
21
+ var loading = true;
22
+ var error = null;
23
+ var page = 1;
24
+ var perPage = 25;
25
+ var total = 0;
26
+ var statusFilter = '';
27
+ var testTo = '';
28
+ var testSubject = 'Test email';
29
+ var testTemplate = '';
30
+ var testData = '{}';
31
+ var previewHtml = '';
32
+ var verifyMsg = '';
33
+ var actionMsg = '';
34
+
35
+ function loadLogs() {
36
+ loading = true;
37
+ error = null;
38
+ var q = '?page=' + page + '&perPage=' + perPage;
39
+ if (statusFilter) q += '&status=' + encodeURIComponent(statusFilter);
40
+ api.get(API + '/logs' + q).then(function(res) {
41
+ logs = res.data || [];
42
+ total = (res.meta && res.meta.total) || 0;
43
+ loading = false;
44
+ }).catch(function(e) {
45
+ error = e.message || String(e);
46
+ loading = false;
47
+ });
48
+ }
49
+
50
+ function loadTemplates() {
51
+ api.get(API + '/templates').then(function(res) {
52
+ templates = res.data || [];
53
+ }).catch(function() {});
54
+ }
55
+
56
+ return {
57
+ oninit: function() {
58
+ loadTemplates();
59
+ loadLogs();
60
+ },
61
+ view: function() {
62
+ return m('div.p-6.space-y-4', [
63
+ m('div.flex.gap-2.border-b.pb-2', [
64
+ ['logs', 'send', 'preview', 'verify'].map(function(t) {
65
+ return m('button.px-3.py-1.rounded.text-sm', {
66
+ class: tab === t ? 'bg-blue-600 text-white' : 'bg-gray-100',
67
+ onclick: function() { tab = t; actionMsg = ''; verifyMsg = ''; },
68
+ }, t === 'logs' ? 'Logs' : t === 'send' ? 'Test send' : t === 'preview' ? 'Preview' : 'Verify SMTP');
69
+ }),
70
+ ]),
71
+
72
+ tab === 'logs' && (loading
73
+ ? m('div.text-gray-500', 'Loading…')
74
+ : error
75
+ ? m('div.text-red-600', error)
76
+ : m('div.space-y-3', [
77
+ m('div.flex.flex-wrap.gap-2.items-end', [
78
+ m('select.border.rounded.px-2.py-1', {
79
+ value: statusFilter,
80
+ onchange: function(e) { statusFilter = e.target.value; page = 1; loadLogs(); },
81
+ }, [
82
+ m('option', { value: '' }, 'All statuses'),
83
+ m('option', { value: 'sent' }, 'sent'),
84
+ m('option', { value: 'failed' }, 'failed'),
85
+ ]),
86
+ m('button.border.px-3.py-1.rounded', { onclick: loadLogs }, 'Refresh'),
87
+ m('button.border.px-3.py-1.rounded.text-red-700', {
88
+ onclick: function() {
89
+ if (!confirm('Delete logs older than 90 days?')) return;
90
+ api.post(API + '/purge', { days: 90 }).then(function() { loadLogs(); });
91
+ },
92
+ }, 'Purge 90d'),
93
+ ]),
94
+ m('div.text-sm.text-gray-500', 'Total: ' + total),
95
+ m('div.overflow-x-auto.border.rounded', [
96
+ m('table.min-w-full.text-sm', [
97
+ m('thead.bg-gray-50', m('tr', [
98
+ m('th.text-left.p-2', 'Time'),
99
+ m('th.text-left.p-2', 'To'),
100
+ m('th.text-left.p-2', 'Subject'),
101
+ m('th.text-left.p-2', 'Template'),
102
+ m('th.text-left.p-2', 'Status'),
103
+ m('th.text-left.p-2', 'Error'),
104
+ ])),
105
+ m('tbody', logs.map(function(r) {
106
+ return m('tr.border-t', [
107
+ m('td.p-2.whitespace-nowrap', r.created_at || ''),
108
+ m('td.p-2', r.to || ''),
109
+ m('td.p-2', r.subject || ''),
110
+ m('td.p-2', r.template_id || '—'),
111
+ m('td.p-2', r.status),
112
+ m('td.p-2.text-red-600.max-w-xs.truncate', { title: r.error || '' }, r.error || ''),
113
+ ]);
114
+ })),
115
+ ]),
116
+ ]),
117
+ ])),
118
+
119
+ tab === 'send' && m('div.space-y-3.max-w-xl', [
120
+ m('div', [
121
+ m('label.block.text-xs.text-gray-500.mb-1', 'To'),
122
+ m('input.border.rounded.w-full.px-2.py-1', {
123
+ value: testTo,
124
+ oninput: function(e) { testTo = e.target.value; },
125
+ placeholder: 'user@example.com',
126
+ }),
127
+ ]),
128
+ m('div', [
129
+ m('label.block.text-xs.text-gray-500.mb-1', 'Subject'),
130
+ m('input.border.rounded.w-full.px-2.py-1', {
131
+ value: testSubject,
132
+ oninput: function(e) { testSubject = e.target.value; },
133
+ }),
134
+ ]),
135
+ m('div', [
136
+ m('label.block.text-xs.text-gray-500.mb-1', 'Template'),
137
+ m('select.border.rounded.w-full.px-2.py-1', {
138
+ value: testTemplate,
139
+ onchange: function(e) { testTemplate = e.target.value; },
140
+ }, [
141
+ m('option', { value: '' }, '(none — use data only if template empty)'),
142
+ ].concat(templates.map(function(t) {
143
+ return m('option', { value: t.id }, t.id);
144
+ }))),
145
+ ]),
146
+ m('div', [
147
+ m('label.block.text-xs.text-gray-500.mb-1', 'Data (JSON)'),
148
+ m('textarea.border.rounded.w-full.px-2.py-1.font-mono.text-xs', {
149
+ rows: 4,
150
+ value: testData,
151
+ oninput: function(e) { testData = e.target.value; },
152
+ }),
153
+ ]),
154
+ m('button.bg-blue-600.text-white.px-4.py-2.rounded', {
155
+ onclick: function() {
156
+ actionMsg = 'Sending…';
157
+ api.post(API + '/send-test', {
158
+ to: testTo,
159
+ subject: testSubject,
160
+ template: testTemplate || undefined,
161
+ data: testData,
162
+ }).then(function() {
163
+ actionMsg = 'Sent.';
164
+ loadLogs();
165
+ }).catch(function(e) {
166
+ actionMsg = e.message || String(e);
167
+ });
168
+ },
169
+ }, 'Send test'),
170
+ actionMsg && m('div.text-sm', actionMsg),
171
+ ]),
172
+
173
+ tab === 'preview' && m('div.space-y-3', [
174
+ m('div.max-w-xl.space-y-2', [
175
+ m('select.border.rounded.w-full.px-2.py-1', {
176
+ value: testTemplate,
177
+ onchange: function(e) { testTemplate = e.target.value; },
178
+ }, templates.map(function(t) {
179
+ return m('option', { value: t.id }, t.id);
180
+ })),
181
+ m('textarea.border.rounded.w-full.px-2.py-1.font-mono.text-xs', {
182
+ rows: 3,
183
+ value: testData,
184
+ oninput: function(e) { testData = e.target.value; },
185
+ }),
186
+ m('button.border.px-3.py-1.rounded', {
187
+ onclick: function() {
188
+ api.post(API + '/preview', { template: testTemplate, data: testData }).then(function(res) {
189
+ previewHtml = res.html || '';
190
+ });
191
+ },
192
+ }, 'Preview'),
193
+ ]),
194
+ previewHtml && m('iframe.border.rounded.w-full', {
195
+ style: 'height: 420px',
196
+ srcdoc: previewHtml,
197
+ }),
198
+ ]),
199
+
200
+ tab === 'verify' && m('div.space-y-3', [
201
+ m('button.bg-blue-600.text-white.px-4.py-2.rounded', {
202
+ onclick: function() {
203
+ verifyMsg = 'Checking…';
204
+ api.post(API + '/verify').then(function(res) {
205
+ verifyMsg = res.message || 'OK';
206
+ }).catch(function(e) {
207
+ verifyMsg = e.message || String(e);
208
+ });
209
+ },
210
+ }, 'Verify SMTP connection'),
211
+ verifyMsg && m('div.text-sm', verifyMsg),
212
+ ]),
213
+ ]);
214
+ },
215
+ };
216
+ }
217
+
218
+ window.__customPages = window.__customPages || {};
219
+ window.__customPages['email'] = EmailPage;
220
+ })();
221
+ `;
222
+ }
223
+
224
+ module.exports = {
225
+ generateEmailComponent,
226
+ };