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.
- package/README.md +104 -6
- package/bin/commands/doctor.js +83 -0
- package/bin/commands/favicon-generate.js +3 -1
- package/core/auth/hash.js +1 -1
- package/core/auth/index.js +30 -0
- package/core/auth/manager.js +190 -0
- package/core/auth/tokens.js +135 -0
- package/core/kernel/app.js +7 -1
- package/core/orm/json-fields.js +1 -1
- package/core/orm/migrations/scaffold.js +5 -0
- package/core/orm/schema-helpers.js +18 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +32 -0
- package/index.js +2 -0
- package/package.json +5 -2
- package/plugins/admin-panel/client/parts/07-model-list.js +18 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +269 -0
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/csrf/index.js +222 -0
- package/plugins/email/admin-component.js +226 -0
- package/plugins/email/api-handlers.js +139 -0
- package/plugins/email/auth-bridge.js +209 -0
- package/plugins/email/bundled-templates.js +68 -0
- package/plugins/email/fs-env.js +101 -0
- package/plugins/email/index.js +186 -0
- package/plugins/email/manifest.js +25 -0
- package/plugins/email/migration-template.js +40 -0
- package/plugins/email/purge.js +28 -0
- package/plugins/email/render.js +154 -0
- package/plugins/email/service.js +244 -0
- package/plugins/email/template-registry.js +134 -0
- package/plugins/email/templates/auth-email-verify.mjml +13 -0
- package/plugins/email/templates/auth-password-reset.mjml +13 -0
- package/plugins/email/templates/auth-welcome.mjml +11 -0
- package/plugins/email/transport.js +71 -0
- package/plugins/index.js +4 -0
- package/plugins/upload/index.js +53 -30
- package/src/file-router.js +75 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +1 -0
package/plugins/upload/index.js
CHANGED
|
@@ -54,12 +54,12 @@ function extensionFromName(name) {
|
|
|
54
54
|
* @param {string} fieldName
|
|
55
55
|
* @returns {import('express').RequestHandler}
|
|
56
56
|
*/
|
|
57
|
-
function
|
|
57
|
+
function createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles) {
|
|
58
58
|
const upload = multer({
|
|
59
59
|
storage: multer.memoryStorage(),
|
|
60
|
-
limits: { fileSize: maxBytes, files: 1 },
|
|
60
|
+
limits: { fileSize: maxBytes, files: multiple ? maxFiles : 1 },
|
|
61
61
|
});
|
|
62
|
-
return upload.single(fieldName);
|
|
62
|
+
return multiple ? upload.array(fieldName, maxFiles) : upload.single(fieldName);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
@@ -91,6 +91,8 @@ function multerErrorResponse(err) {
|
|
|
91
91
|
* @param {string[]|null} [options.extensionAllowlist] — Optional extra guard on original extension
|
|
92
92
|
* @param {import('express').RequestHandler|import('express').RequestHandler[]} [options.middleware]
|
|
93
93
|
* @param {string} [options.fieldName='file'] — Multipart field name
|
|
94
|
+
* @param {boolean} [options.multiple=false] — Allow multiple file uploads
|
|
95
|
+
* @param {number} [options.maxFiles=10] — Max files to allow when multiple is true
|
|
94
96
|
* @returns {Object} Webspresso plugin
|
|
95
97
|
*/
|
|
96
98
|
function uploadPlugin(options = {}) {
|
|
@@ -103,6 +105,8 @@ function uploadPlugin(options = {}) {
|
|
|
103
105
|
extensionAllowlist = null,
|
|
104
106
|
middleware = [],
|
|
105
107
|
fieldName = 'file',
|
|
108
|
+
multiple = false,
|
|
109
|
+
maxFiles = 10,
|
|
106
110
|
} = options;
|
|
107
111
|
|
|
108
112
|
const normalizedPath = normalizeRoutePath(routePath);
|
|
@@ -115,7 +119,7 @@ function uploadPlugin(options = {}) {
|
|
|
115
119
|
});
|
|
116
120
|
|
|
117
121
|
const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
|
|
118
|
-
const parseMultipart =
|
|
122
|
+
const parseMultipart = createMulterMiddleware(maxBytes, fieldName, multiple, maxFiles);
|
|
119
123
|
|
|
120
124
|
return {
|
|
121
125
|
name: 'upload',
|
|
@@ -133,39 +137,58 @@ function uploadPlugin(options = {}) {
|
|
|
133
137
|
return res.status(status).json({ error: message, message });
|
|
134
138
|
}
|
|
135
139
|
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
140
|
+
const files = multiple ? (req.files || []) : (req.file ? [req.file] : []);
|
|
141
|
+
if (files.length === 0) {
|
|
138
142
|
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
(
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
// Validate all files first
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
if (!file || !file.buffer) {
|
|
148
|
+
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
152
|
+
const originalName = file.originalname || 'upload';
|
|
153
|
+
const ext = extensionFromName(originalName);
|
|
154
|
+
|
|
155
|
+
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
156
|
+
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
extensionAllowlist &&
|
|
160
|
+
extensionAllowlist.length &&
|
|
161
|
+
(!ext || !extensionAllowlist.includes(ext))
|
|
162
|
+
) {
|
|
163
|
+
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
164
|
+
}
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
try {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
originalName
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
+
const uploadPromises = files.map(async (file) => {
|
|
169
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
170
|
+
const originalName = file.originalname || 'upload';
|
|
171
|
+
const result = await provider.put({
|
|
172
|
+
buffer: file.buffer,
|
|
173
|
+
originalName,
|
|
174
|
+
mimeType: mime,
|
|
175
|
+
size: file.size,
|
|
176
|
+
req,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
url: result.publicUrl,
|
|
180
|
+
publicUrl: result.publicUrl,
|
|
181
|
+
key: result.key,
|
|
182
|
+
};
|
|
168
183
|
});
|
|
184
|
+
|
|
185
|
+
const results = await Promise.all(uploadPromises);
|
|
186
|
+
|
|
187
|
+
if (multiple) {
|
|
188
|
+
res.json(results);
|
|
189
|
+
} else {
|
|
190
|
+
res.json(results[0]);
|
|
191
|
+
}
|
|
169
192
|
} catch (e) {
|
|
170
193
|
const message = e instanceof Error ? e.message : String(e);
|
|
171
194
|
res.status(500).json({ error: message, message });
|
package/src/file-router.js
CHANGED
|
@@ -644,6 +644,81 @@ function mountPages(app, options) {
|
|
|
644
644
|
|
|
645
645
|
// Get absolute path to pages directory
|
|
646
646
|
const absolutePagesDir = path.resolve(pagesDir);
|
|
647
|
+
|
|
648
|
+
// Routing and file-system checks at startup
|
|
649
|
+
function checkRoutesAtStartup() {
|
|
650
|
+
function getGenericSegment(name) {
|
|
651
|
+
if (name.startsWith('[') && name.endsWith(']')) {
|
|
652
|
+
const inner = name.slice(1, -1);
|
|
653
|
+
if (inner.startsWith('...')) return '*';
|
|
654
|
+
return ':param';
|
|
655
|
+
}
|
|
656
|
+
return name;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function checkDir(dirPath, relativeDir = '', isApi = false) {
|
|
660
|
+
if (!fs.existsSync(dirPath)) return;
|
|
661
|
+
const children = fs.readdirSync(dirPath);
|
|
662
|
+
const kept = [];
|
|
663
|
+
|
|
664
|
+
for (const child of children) {
|
|
665
|
+
if (child.startsWith('_') || child === 'locales') continue;
|
|
666
|
+
const fullPath = path.join(dirPath, child);
|
|
667
|
+
const relPath = relativeDir ? path.join(relativeDir, child) : child;
|
|
668
|
+
const stats = fs.statSync(fullPath);
|
|
669
|
+
|
|
670
|
+
// 1. Check case-sensitivity issues
|
|
671
|
+
if (child !== child.toLowerCase()) {
|
|
672
|
+
console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
if (stats.isDirectory()) {
|
|
676
|
+
if (child === 'api' && relativeDir === '') {
|
|
677
|
+
checkDir(fullPath, relPath, true);
|
|
678
|
+
} else {
|
|
679
|
+
checkDir(fullPath, relPath, isApi);
|
|
680
|
+
if (!isApi) {
|
|
681
|
+
kept.push({ name: child, isDir: true, original: child });
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
const ext = path.extname(child);
|
|
686
|
+
if (isApi) {
|
|
687
|
+
if (ext === '.js') {
|
|
688
|
+
const base = path.basename(child, ext);
|
|
689
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
if (ext === '.njk') {
|
|
693
|
+
const base = path.basename(child, ext);
|
|
694
|
+
kept.push({ name: base, isDir: false, original: child });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// 2. Check dynamic route collisions
|
|
701
|
+
const groups = new Map();
|
|
702
|
+
for (const item of kept) {
|
|
703
|
+
const normalized = getGenericSegment(item.name);
|
|
704
|
+
if (!groups.has(normalized)) {
|
|
705
|
+
groups.set(normalized, []);
|
|
706
|
+
}
|
|
707
|
+
groups.get(normalized).push(item);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
for (const [norm, items] of groups.entries()) {
|
|
711
|
+
if (items.length > 1) {
|
|
712
|
+
const namesList = items.map(item => `"${item.original}"`).join(' and ');
|
|
713
|
+
console.warn(`[webspresso] WARNING: Dynamic route collision in "pages/${relativeDir.split(path.sep).join('/')}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
checkDir(absolutePagesDir, '');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
checkRoutesAtStartup();
|
|
647
722
|
|
|
648
723
|
// Load global hooks
|
|
649
724
|
const globalHooks = loadGlobalHooks(absolutePagesDir, isDev);
|
|
@@ -202,6 +202,7 @@ Pass **`db`** into **`createApp({ db })`** so **`ctx.db`** works in pages and pl
|
|
|
202
202
|
| `siteAnalyticsPlugin` | Self-hosted page views + admin charts |
|
|
203
203
|
| `auditLogPlugin` | Admin mutation audit trail |
|
|
204
204
|
| `recaptchaPlugin` | v2/v3 + middleware |
|
|
205
|
+
| `emailPlugin` | MJML + Nodemailer sending with optional admin UI and auth bridge |
|
|
205
206
|
| `seoCheckerPlugin` | Dev SEO panel |
|
|
206
207
|
| `restResourcePlugin` | Opt-in REST CRUD from models; `?include=` uses ORM eager load (single-level relations only) |
|
|
207
208
|
| `ormCacheAdminPlugin` | Admin page for ORM cache metrics / purge / invalidate (`db.cache` required) |
|