webspresso 0.0.79 → 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.
Files changed (39) hide show
  1. package/README.md +104 -6
  2. package/bin/commands/doctor.js +83 -0
  3. package/bin/commands/favicon-generate.js +3 -1
  4. package/core/auth/hash.js +1 -1
  5. package/core/auth/index.js +30 -0
  6. package/core/auth/manager.js +190 -0
  7. package/core/auth/tokens.js +135 -0
  8. package/core/kernel/app.js +7 -1
  9. package/core/orm/json-fields.js +1 -1
  10. package/core/orm/migrations/scaffold.js +5 -0
  11. package/core/orm/schema-helpers.js +18 -0
  12. package/core/orm/types.js +1 -1
  13. package/index.d.ts +32 -0
  14. package/index.js +2 -0
  15. package/package.json +5 -2
  16. package/plugins/admin-panel/client/parts/07-model-list.js +18 -0
  17. package/plugins/admin-panel/field-renderers/file-upload.js +269 -0
  18. package/plugins/admin-panel/field-renderers/index.js +1 -0
  19. package/plugins/csrf/index.js +222 -0
  20. package/plugins/email/admin-component.js +226 -0
  21. package/plugins/email/api-handlers.js +139 -0
  22. package/plugins/email/auth-bridge.js +209 -0
  23. package/plugins/email/bundled-templates.js +68 -0
  24. package/plugins/email/fs-env.js +101 -0
  25. package/plugins/email/index.js +186 -0
  26. package/plugins/email/manifest.js +25 -0
  27. package/plugins/email/migration-template.js +40 -0
  28. package/plugins/email/purge.js +28 -0
  29. package/plugins/email/render.js +154 -0
  30. package/plugins/email/service.js +244 -0
  31. package/plugins/email/template-registry.js +134 -0
  32. package/plugins/email/templates/auth-email-verify.mjml +13 -0
  33. package/plugins/email/templates/auth-password-reset.mjml +13 -0
  34. package/plugins/email/templates/auth-welcome.mjml +11 -0
  35. package/plugins/email/transport.js +71 -0
  36. package/plugins/index.js +4 -0
  37. package/plugins/upload/index.js +53 -30
  38. package/src/file-router.js +75 -0
  39. package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
@@ -50,7 +50,13 @@ function createApp(options = {}) {
50
50
  }
51
51
  };
52
52
  events.on(flowDef.trigger, handler);
53
- const unregister = () => events.off(flowDef.trigger, handler);
53
+ const unregister = () => {
54
+ events.off(flowDef.trigger, handler);
55
+ const idx = flowHandles.findIndex((h) => h.unregister === unregister);
56
+ if (idx !== -1) {
57
+ flowHandles.splice(idx, 1);
58
+ }
59
+ };
54
60
  flowHandles.push({
55
61
  id,
56
62
  trigger: flowDef.trigger,
@@ -12,7 +12,7 @@ function getJsonColumns(model) {
12
12
  const jsonCols = new Set();
13
13
  if (model.columns) {
14
14
  for (const [name, meta] of model.columns) {
15
- if (meta.type === 'json') {
15
+ if (meta.type === 'json' || meta.type === 'array' || meta.type === 'files') {
16
16
  jsonCols.add(name);
17
17
  }
18
18
  }
@@ -153,6 +153,11 @@ function generateColumnLine(columnName, meta) {
153
153
  parts.push(`table.json('${columnName}')`);
154
154
  break;
155
155
 
156
+ case 'files':
157
+ // Multiple files are stored as JSON in database
158
+ parts.push(`table.json('${columnName}')`);
159
+ break;
160
+
156
161
  case 'enum':
157
162
  const enumValues = meta.enumValues || [];
158
163
  const valuesStr = enumValues.map(v => `'${v}'`).join(', ');
@@ -439,6 +439,24 @@ function createSchemaHelpers(z) {
439
439
  }, z);
440
440
  },
441
441
 
442
+ /**
443
+ * Multiple Files / URLs column (stored as JSON array in database)
444
+ * @param {Partial<import('./types').ColumnMeta>} [options={}]
445
+ * @returns {SchemaBuilder}
446
+ */
447
+ files(options = {}) {
448
+ const { nullable = false, ...rest } = options;
449
+ let schema = z.array(z.string());
450
+ if (nullable) {
451
+ schema = schema.nullable().optional();
452
+ }
453
+ return createSchemaBuilder(schema, {
454
+ type: 'files',
455
+ nullable,
456
+ ...rest,
457
+ }, z);
458
+ },
459
+
442
460
  /**
443
461
  * Integer column
444
462
  * @param {Partial<import('./types').ColumnMeta>} [options={}]
package/core/orm/types.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // ============================================================================
10
10
 
11
11
  /**
12
- * @typedef {'id'|'string'|'text'|'file'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
12
+ * @typedef {'id'|'string'|'text'|'file'|'files'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
13
13
  */
14
14
 
15
15
  /**
package/index.d.ts CHANGED
@@ -74,6 +74,15 @@ export interface CreateAppResult {
74
74
 
75
75
  export function createApp(options?: CreateAppOptions): CreateAppResult;
76
76
 
77
+ export function resolveClientRuntime(options?: {
78
+ clientRuntime?: {
79
+ alpine?: boolean | Record<string, unknown>;
80
+ swup?: boolean | Record<string, unknown>;
81
+ };
82
+ }): { alpine: boolean; swup: boolean };
83
+
84
+ export const CLIENT_RUNTIME_BASE: string;
85
+
77
86
  // --- App context ---
78
87
 
79
88
  export function attachDbMiddleware(req: Request, res: Response, next: NextFunction): void;
@@ -595,6 +604,29 @@ export interface RateLimitPluginOptions {
595
604
 
596
605
  export function rateLimitPlugin(options?: RateLimitPluginOptions): WebspressoPlugin;
597
606
 
607
+ export interface CsrfCookieOptions {
608
+ key?: string;
609
+ signed?: boolean;
610
+ path?: string;
611
+ httpOnly?: boolean;
612
+ secure?: boolean;
613
+ sameSite?: 'lax' | 'strict' | 'none';
614
+ maxAge?: number;
615
+ [key: string]: unknown;
616
+ }
617
+
618
+ export interface CsrfPluginOptions {
619
+ cookie?: boolean | CsrfCookieOptions;
620
+ sessionKey?: string;
621
+ methods?: string[];
622
+ ignorePaths?: Array<string | RegExp | ((req: Request) => boolean)>;
623
+ global?: boolean;
624
+ errorMessage?: string;
625
+ errorStatus?: number;
626
+ }
627
+
628
+ export function csrfPlugin(options?: CsrfPluginOptions): WebspressoPlugin;
629
+
598
630
  export interface ContentPluginOptions {
599
631
  db: import('./index').Database;
600
632
  adminPath?: string;
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.79",
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
  }
@@ -128,6 +128,24 @@ function formatCellValue(value, col, record = null) {
128
128
  }
129
129
  return short || m('span.text-gray-400', '—');
130
130
  }
131
+
132
+ case 'files': {
133
+ if (Array.isArray(value)) {
134
+ return value.length > 0
135
+ ? m('div.flex.gap-1.items-center', [
136
+ m('span.text-xs.bg-gray-100.dark:bg-slate-800.px-2.py-1.rounded.mr-1', `${value.length} files`),
137
+ ...value.slice(0, 2).map(s => {
138
+ const isImg = /\.(jpe?g|png|gif|webp|svg|avif|bmp|ico)(\?|#|$)/i.test(s);
139
+ if (isImg) {
140
+ return m('img.h-6.w-6.rounded.object-cover.border.border-gray-200.dark:border-slate-700.bg-white', { src: s });
141
+ }
142
+ return m('a.text-xs.text-indigo-600.dark:text-indigo-400.hover:underline', { href: s, target: '_blank' }, '📄');
143
+ })
144
+ ])
145
+ : m('span.text-gray-400.dark:text-slate-500.italic', 'empty');
146
+ }
147
+ return String(value);
148
+ }
131
149
 
132
150
  default: {
133
151
  const str = String(value);
@@ -219,6 +219,212 @@ module.exports = {
219
219
  ]);
220
220
  },
221
221
  },
222
+
223
+ FilesUploadField: {
224
+ oninit: (vnode) => {
225
+ vnode.state.uploading = false;
226
+ vnode.state.draggedIndex = null;
227
+ vnode.state.dragOverIndex = null;
228
+ },
229
+ oncreate: (vnode) => {
230
+ const { name, meta = {} } = vnode.attrs;
231
+ if (!getUploadUrlFromAdminConfig()) return;
232
+
233
+ const dropZoneId = 'drop-zone-multiple-' + name;
234
+ const dropZone = document.getElementById(dropZoneId);
235
+ if (!dropZone) return;
236
+
237
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => {
238
+ dropZone.addEventListener(eventName, (e) => {
239
+ e.preventDefault();
240
+ e.stopPropagation();
241
+ });
242
+ });
243
+
244
+ ['dragenter', 'dragover'].forEach((eventName) => {
245
+ dropZone.addEventListener(eventName, () => {
246
+ dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
247
+ });
248
+ });
249
+
250
+ ['dragleave', 'drop'].forEach((eventName) => {
251
+ dropZone.addEventListener(eventName, () => {
252
+ dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-slate-800');
253
+ });
254
+ });
255
+
256
+ dropZone.addEventListener('drop', (e) => {
257
+ const files = e.dataTransfer.files;
258
+ if (files.length > 0) {
259
+ handleMultipleFilesUpload(files, vnode, meta);
260
+ }
261
+ });
262
+
263
+ const fileInput = dropZone.querySelector('input[type=file]');
264
+ if (fileInput) {
265
+ fileInput.addEventListener('change', (e) => {
266
+ if (e.target.files.length > 0) {
267
+ handleMultipleFilesUpload(e.target.files, vnode, meta);
268
+ }
269
+ });
270
+ }
271
+ },
272
+ view: (vnode) => {
273
+ const { name, value = [], meta = {}, required = false, readonly = false } = vnode.attrs;
274
+ const state = vnode.state;
275
+ const dropZoneId = 'drop-zone-multiple-' + name;
276
+ const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
277
+ const accept = meta.accept || '*/*';
278
+ const uploadUrl = getUploadUrlFromAdminConfig();
279
+ const label = meta.label || name;
280
+
281
+ const urls = Array.isArray(value) ? value : (value ? [value] : []);
282
+
283
+ if (!uploadUrl) {
284
+ return m('.mb-4', [
285
+ m(
286
+ 'label.block.text-sm.font-medium.mb-2',
287
+ label,
288
+ required ? m('span.text-red-500', ' *') : null
289
+ ),
290
+ m('p.text-xs.text-amber-700.dark:text-amber-400.mb-2', 'Upload URL is not configured.'),
291
+ ]);
292
+ }
293
+
294
+ return m('.mb-4', [
295
+ m(
296
+ 'label.block.text-sm.font-medium.mb-2',
297
+ label,
298
+ required ? m('span.text-red-500', ' *') : null
299
+ ),
300
+
301
+ urls.length > 0
302
+ ? m('.grid.grid-cols-2.sm:grid-cols-3.md:grid-cols-4.gap-4.mb-3', urls.map((url, idx) => {
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
+ }, [
346
+ shouldShowImagePreview(url, accept)
347
+ ? m('a.block', { href: url, target: '_blank', rel: 'noopener noreferrer', draggable: false },
348
+ m('img.h-32.w-full.rounded.object-contain.bg-white.dark:bg-slate-950', {
349
+ src: url,
350
+ alt: `${label} ${idx + 1}`,
351
+ loading: 'lazy',
352
+ draggable: false,
353
+ })
354
+ )
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', {
356
+ href: url,
357
+ target: '_blank',
358
+ rel: 'noopener noreferrer',
359
+ draggable: false,
360
+ }, url.substring(url.lastIndexOf('/') + 1) || url),
361
+ !readonly
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', {
363
+ type: 'button',
364
+ onclick: () => {
365
+ const newUrls = urls.filter((_, i) => i !== idx);
366
+ if (vnode.attrs.onchange) vnode.attrs.onchange(newUrls);
367
+ },
368
+ title: 'Remove file',
369
+ }, [
370
+ m('svg.w-4.h-4', { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' },
371
+ m('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2', d: 'M6 18L18 6M6 6l12 12' })
372
+ )
373
+ ])
374
+ : null,
375
+ ]);
376
+ }))
377
+ : null,
378
+
379
+ m(
380
+ 'div.border-2.border-dashed.border-gray-300.dark:border-slate-600.rounded.p-6.text-center',
381
+ {
382
+ id: dropZoneId,
383
+ style: readonly || state.uploading ? undefined : 'cursor: pointer;',
384
+ },
385
+ [
386
+ m('input[type=file]', {
387
+ class: 'hidden',
388
+ id: 'file-input-multiple-' + name,
389
+ accept,
390
+ multiple: true,
391
+ disabled: readonly || state.uploading,
392
+ onchange: (e) => {
393
+ if (e.target.files.length > 0) {
394
+ handleMultipleFilesUpload(e.target.files, vnode, meta);
395
+ }
396
+ },
397
+ }),
398
+ readonly
399
+ ? null
400
+ : m('div', [
401
+ state.uploading
402
+ ? m('p.text-blue-600.dark:text-blue-400.animate-pulse', 'Uploading files...')
403
+ : m('div', [
404
+ m(
405
+ 'p.text-gray-600.dark:text-slate-400.mb-2',
406
+ 'Drag and drop files here, or'
407
+ ),
408
+ m(
409
+ 'label.text-blue-600.hover:text-blue-800.cursor-pointer',
410
+ { for: 'file-input-multiple-' + name },
411
+ 'browse'
412
+ ),
413
+ ]),
414
+ ]),
415
+ ]
416
+ ),
417
+ m('input[type=hidden]', {
418
+ name,
419
+ value: JSON.stringify(urls),
420
+ }),
421
+ m(
422
+ 'p.text-xs.text-gray-500.mt-1',
423
+ 'Max ' + Math.round(maxSize / 1024 / 1024) + ' MB per file (server enforces limits)'
424
+ ),
425
+ ]);
426
+ },
427
+ },
222
428
  };
223
429
 
224
430
  /**
@@ -272,3 +478,66 @@ async function handleFileUpload(file, vnode, meta) {
272
478
  alert(err.message || 'Upload failed');
273
479
  }
274
480
  }
481
+
482
+ /**
483
+ * @param {FileList} files
484
+ * @param {import('mithril').Vnode} vnode
485
+ * @param {Object} meta
486
+ */
487
+ async function handleMultipleFilesUpload(files, vnode, meta) {
488
+ const maxSize = meta.maxSize || meta.maxBytes || 10 * 1024 * 1024;
489
+ const uploadUrl = getUploadUrlFromAdminConfig();
490
+ if (!uploadUrl) {
491
+ alert('Upload URL is not configured.');
492
+ return;
493
+ }
494
+
495
+ const currentUrls = Array.isArray(vnode.attrs.value)
496
+ ? [...vnode.attrs.value]
497
+ : (vnode.attrs.value ? [vnode.attrs.value] : []);
498
+
499
+ vnode.state.uploading = true;
500
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
501
+
502
+ const uploadPromises = Array.from(files).map(async (file) => {
503
+ if (file.size > maxSize) {
504
+ alert(`File ${file.name} is too large (max ${Math.round(maxSize / 1024 / 1024)} MB) and was skipped.`);
505
+ return null;
506
+ }
507
+
508
+ const fd = new FormData();
509
+ fd.append('file', file);
510
+
511
+ try {
512
+ const res = await fetch(uploadUrl, { method: 'POST', body: fd, credentials: 'include' });
513
+ let data = {};
514
+ try {
515
+ data = await res.json();
516
+ } catch {
517
+ data = {};
518
+ }
519
+ if (!res.ok) {
520
+ alert(`Upload failed for ${file.name}: ${data.message || data.error || res.status}`);
521
+ return null;
522
+ }
523
+ return data.url || data.publicUrl || '';
524
+ } catch (err) {
525
+ alert(`Upload failed for ${file.name}: ${err.message}`);
526
+ return null;
527
+ }
528
+ });
529
+
530
+ try {
531
+ const results = await Promise.all(uploadPromises);
532
+ const newUrls = results.filter(Boolean);
533
+ if (newUrls.length > 0) {
534
+ const updatedUrls = [...currentUrls, ...newUrls];
535
+ if (vnode.attrs.onchange) {
536
+ vnode.attrs.onchange(updatedUrls);
537
+ }
538
+ }
539
+ } finally {
540
+ vnode.state.uploading = false;
541
+ if (typeof m !== 'undefined' && m.redraw) m.redraw();
542
+ }
543
+ }
@@ -79,6 +79,7 @@ function initializeDefaultRenderers() {
79
79
  registerFieldRenderer('rich-text', richTextRenderer.RichTextField);
80
80
  registerFieldRenderer('file-upload', fileUploadRenderer.FileUploadField);
81
81
  registerFieldRenderer('file', fileUploadRenderer.FileUploadField);
82
+ registerFieldRenderer('files', fileUploadRenderer.FilesUploadField);
82
83
 
83
84
  // Relation types
84
85
  registerFieldRenderer('belongsTo', relationRenderers.BelongsToField);