webspresso 0.0.67 → 0.0.69

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.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * File upload plugin — multipart parsing, validation, pluggable storage.
3
+ * @module plugins/upload
4
+ */
5
+
6
+ const multer = require('multer');
7
+ const path = require('path');
8
+ const { createLocalFileProvider } = require('./local-file-provider');
9
+
10
+ /**
11
+ * @typedef {import('express').Request} ExpressRequest
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} UploadPutArgs
16
+ * @property {Buffer} [buffer]
17
+ * @property {import('stream').Readable} [stream]
18
+ * @property {string} originalName
19
+ * @property {string} mimeType
20
+ * @property {number} size
21
+ * @property {ExpressRequest} req
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} UploadPutResult
26
+ * @property {string} publicUrl
27
+ * @property {string} [key]
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} UploadStorageProvider
32
+ * @property {(args: UploadPutArgs) => Promise<UploadPutResult>} put
33
+ */
34
+
35
+ /**
36
+ * @param {string} [p]
37
+ * @returns {string}
38
+ */
39
+ function normalizeRoutePath(p) {
40
+ const s = (p || '/api/upload').trim();
41
+ return s.startsWith('/') ? s : `/${s}`;
42
+ }
43
+
44
+ /**
45
+ * @param {string} [name]
46
+ * @returns {string} lowercase extension without dot
47
+ */
48
+ function extensionFromName(name) {
49
+ return path.extname(path.basename(name || '')).replace(/^\./, '').toLowerCase();
50
+ }
51
+
52
+ /**
53
+ * @param {number} maxBytes
54
+ * @param {string} fieldName
55
+ * @returns {import('express').RequestHandler}
56
+ */
57
+ function createMulterSingleMiddleware(maxBytes, fieldName) {
58
+ const upload = multer({
59
+ storage: multer.memoryStorage(),
60
+ limits: { fileSize: maxBytes, files: 1 },
61
+ });
62
+ return upload.single(fieldName);
63
+ }
64
+
65
+ /**
66
+ * @param {unknown} err
67
+ * @returns {{ status: number, message: string }}
68
+ */
69
+ function multerErrorResponse(err) {
70
+ if (err instanceof multer.MulterError) {
71
+ if (err.code === 'LIMIT_FILE_SIZE') {
72
+ return { status: 413, message: 'File too large' };
73
+ }
74
+ if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
75
+ return { status: 400, message: 'Invalid upload (too many files or unexpected field)' };
76
+ }
77
+ return { status: 400, message: err.message || 'Upload error' };
78
+ }
79
+ return { status: 400, message: err instanceof Error ? err.message : String(err) };
80
+ }
81
+
82
+ /**
83
+ * @param {Object} [options]
84
+ * @param {string} [options.path='/api/upload'] — POST route
85
+ * @param {UploadStorageProvider} [options.provider] — Storage backend; default: local disk
86
+ * @param {Object} [options.local] — Shorthand for createLocalFileProvider when provider omitted
87
+ * @param {string} [options.local.destDir]
88
+ * @param {string} [options.local.publicBasePath]
89
+ * @param {number} [options.maxBytes=10485760] — 10 MiB default
90
+ * @param {string[]|null} [options.mimeAllowlist] — If set, reject other MIME types (415)
91
+ * @param {string[]|null} [options.extensionAllowlist] — Optional extra guard on original extension
92
+ * @param {import('express').RequestHandler|import('express').RequestHandler[]} [options.middleware]
93
+ * @param {string} [options.fieldName='file'] — Multipart field name
94
+ * @returns {Object} Webspresso plugin
95
+ */
96
+ function uploadPlugin(options = {}) {
97
+ const {
98
+ path: routePath = '/api/upload',
99
+ provider: userProvider,
100
+ local,
101
+ maxBytes = 10 * 1024 * 1024,
102
+ mimeAllowlist = null,
103
+ extensionAllowlist = null,
104
+ middleware = [],
105
+ fieldName = 'file',
106
+ } = options;
107
+
108
+ const normalizedPath = normalizeRoutePath(routePath);
109
+
110
+ const provider =
111
+ userProvider ||
112
+ createLocalFileProvider({
113
+ destDir: local?.destDir,
114
+ publicBasePath: local?.publicBasePath,
115
+ });
116
+
117
+ const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
118
+ const parseMultipart = createMulterSingleMiddleware(maxBytes, fieldName);
119
+
120
+ return {
121
+ name: 'upload',
122
+ version: '1.0.0',
123
+ description: 'Multipart file uploads with pluggable storage',
124
+
125
+ onRoutesReady(ctx) {
126
+ const { app } = ctx;
127
+ app.set('webspresso.uploadPath', normalizedPath);
128
+
129
+ const handler = (req, res) => {
130
+ parseMultipart(req, res, async (err) => {
131
+ if (err) {
132
+ const { status, message } = multerErrorResponse(err);
133
+ return res.status(status).json({ error: message, message });
134
+ }
135
+
136
+ const file = req.file;
137
+ if (!file || !file.buffer) {
138
+ return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
139
+ }
140
+
141
+ const mime = file.mimetype || 'application/octet-stream';
142
+ const originalName = file.originalname || 'upload';
143
+ const ext = extensionFromName(originalName);
144
+
145
+ if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
146
+ return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
147
+ }
148
+ if (
149
+ extensionAllowlist &&
150
+ extensionAllowlist.length &&
151
+ (!ext || !extensionAllowlist.includes(ext))
152
+ ) {
153
+ return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
154
+ }
155
+
156
+ try {
157
+ const result = await provider.put({
158
+ buffer: file.buffer,
159
+ originalName,
160
+ mimeType: mime,
161
+ size: file.size,
162
+ req,
163
+ });
164
+ res.json({
165
+ url: result.publicUrl,
166
+ publicUrl: result.publicUrl,
167
+ key: result.key,
168
+ });
169
+ } catch (e) {
170
+ const message = e instanceof Error ? e.message : String(e);
171
+ res.status(500).json({ error: message, message });
172
+ }
173
+ });
174
+ };
175
+
176
+ ctx.addRoute('post', normalizedPath, ...mw, handler);
177
+
178
+ if (process.env.NODE_ENV !== 'production') {
179
+ console.log(` Upload plugin: POST ${normalizedPath}`);
180
+ }
181
+ },
182
+ };
183
+ }
184
+
185
+ module.exports = {
186
+ uploadPlugin,
187
+ createLocalFileProvider,
188
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Default upload storage: write files to local disk and expose a public URL path.
3
+ * @module plugins/upload/local-file-provider
4
+ */
5
+
6
+ const fs = require('fs/promises');
7
+ const path = require('path');
8
+ const { randomBytes } = require('crypto');
9
+
10
+ /**
11
+ * Common MIME → canonical extension (lowercase, with leading dot).
12
+ * Used so stored filenames match the MIME the server accepted (see uploadPlugin mimeAllowlist).
13
+ * Does not verify file content; spoofed MIME is a separate concern (allowlist + optional magic-byte later).
14
+ */
15
+ const MIME_TO_EXT = {
16
+ 'image/jpeg': '.jpg',
17
+ 'image/png': '.png',
18
+ 'image/gif': '.gif',
19
+ 'image/webp': '.webp',
20
+ 'image/svg+xml': '.svg',
21
+ 'image/avif': '.avif',
22
+ 'application/pdf': '.pdf',
23
+ 'text/plain': '.txt',
24
+ 'text/csv': '.csv',
25
+ 'text/html': '.html',
26
+ 'application/json': '.json',
27
+ 'application/zip': '.zip',
28
+ 'application/gzip': '.gz',
29
+ 'video/mp4': '.mp4',
30
+ 'video/webm': '.webm',
31
+ 'audio/mpeg': '.mp3',
32
+ 'audio/webm': '.weba',
33
+ };
34
+
35
+ /**
36
+ * Normalize public URL prefix (no trailing slash).
37
+ * @param {string} [publicBasePath]
38
+ * @returns {string}
39
+ */
40
+ function normalizePublicBase(publicBasePath) {
41
+ let p = (publicBasePath == null || publicBasePath === '' ? '/uploads' : String(publicBasePath)).trim();
42
+ p = p.replace(/\/+$/, '');
43
+ if (!p.startsWith('/')) {
44
+ p = `/${p}`;
45
+ }
46
+ return p || '/uploads';
47
+ }
48
+
49
+ /**
50
+ * @param {string} [mimeType]
51
+ * @returns {string|null} extension with leading dot, or null if unknown
52
+ */
53
+ function canonicalExtFromMime(mimeType) {
54
+ if (!mimeType || typeof mimeType !== 'string') return null;
55
+ const base = mimeType.split(';')[0].trim().toLowerCase();
56
+ return MIME_TO_EXT[base] || null;
57
+ }
58
+
59
+ /**
60
+ * Prefer a safe extension from the filename; align with MIME when we have a mapping.
61
+ * @param {string} [mimeType]
62
+ * @param {string} extFromName - already normalized (lowercase, with dot or '')
63
+ * @returns {string} extension with leading dot or empty string
64
+ */
65
+ function resolveStoredExtension(mimeType, extFromName) {
66
+ const fromMime = canonicalExtFromMime(mimeType);
67
+ if (fromMime) {
68
+ if (!extFromName || extFromName !== fromMime) {
69
+ return fromMime;
70
+ }
71
+ return extFromName;
72
+ }
73
+ return extFromName || '';
74
+ }
75
+
76
+ /**
77
+ * @param {Object} [options]
78
+ * @param {string} [options.destDir] — Absolute or cwd-relative directory for stored files
79
+ * @param {string} [options.publicBasePath='/uploads'] — URL prefix (first segment of public URL)
80
+ * @returns {import('./index').UploadStorageProvider}
81
+ */
82
+ function createLocalFileProvider(options = {}) {
83
+ const destDir = path.resolve(
84
+ options.destDir || path.join(process.cwd(), 'public', 'uploads')
85
+ );
86
+ const publicBase = normalizePublicBase(options.publicBasePath);
87
+
88
+ return {
89
+ async put({ buffer, originalName, mimeType, size, req }) {
90
+ void size;
91
+ void req;
92
+ await fs.mkdir(destDir, { recursive: true });
93
+
94
+ const base = path.basename(originalName || 'upload');
95
+ let ext = path.extname(base).toLowerCase();
96
+ if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
97
+ ext = '';
98
+ }
99
+
100
+ ext = resolveStoredExtension(mimeType, ext);
101
+
102
+ const storedName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
103
+ const target = path.join(destDir, storedName);
104
+ const resolvedDest = path.resolve(destDir);
105
+ if (!target.startsWith(resolvedDest + path.sep) && target !== resolvedDest) {
106
+ throw new Error('Invalid storage path');
107
+ }
108
+
109
+ await fs.writeFile(target, buffer);
110
+
111
+ const publicUrl = `${publicBase}/${storedName}`.replace(/\/{2,}/g, '/');
112
+ return { publicUrl, key: storedName };
113
+ },
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ createLocalFileProvider,
119
+ normalizePublicBase,
120
+ canonicalExtFromMime,
121
+ resolveStoredExtension,
122
+ };
@@ -16,6 +16,12 @@ const i18nCache = new Map();
16
16
  // Cache for route configs in production
17
17
  const configCache = new Map();
18
18
 
19
+ // Dev-only: avoid require() on every SSR request when the .js file is unchanged (mtime)
20
+ const routeConfigDevCache = new Map();
21
+
22
+ // Cache for API filename -> { method, baseName } (basename keys; stable per process)
23
+ const methodFromFilenameCache = new Map();
24
+
19
25
  /**
20
26
  * Convert a file path to an Express route pattern
21
27
  * @param {string} filePath - Relative path from pages/
@@ -51,26 +57,87 @@ function filePathToRoute(filePath, ext) {
51
57
  return route;
52
58
  }
53
59
 
60
+ /**
61
+ * Metadata for ordering route registration: more specific Express paths must be
62
+ * registered before less specific ones (static before dynamic; more literal
63
+ * segments before fewer; deeper paths before shallower among same class).
64
+ * @param {string} routePath
65
+ * @returns {{ tier: number, literalSegCount: number, paramSegCount: number, depth: number, routePath: string }}
66
+ */
67
+ function routeRegistrationMeta(routePath) {
68
+ const hasCatchAll = routePath.includes('*');
69
+ const hasDynamic = routePath.includes(':');
70
+ let tier;
71
+ if (hasCatchAll) tier = 2;
72
+ else if (hasDynamic) tier = 1;
73
+ else tier = 0;
74
+
75
+ const segments = routePath.split('/').filter(Boolean);
76
+ let literalSegCount = 0;
77
+ let paramSegCount = 0;
78
+ for (const seg of segments) {
79
+ if (seg === '*' || (seg.length > 0 && seg.includes('*'))) continue;
80
+ if (seg.includes(':')) paramSegCount += 1;
81
+ else literalSegCount += 1;
82
+ }
83
+
84
+ return {
85
+ tier,
86
+ literalSegCount,
87
+ paramSegCount,
88
+ depth: segments.length,
89
+ routePath,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Compare two routes for registration order (negative if a before b).
95
+ * @param {{ routePath: string }} a
96
+ * @param {{ routePath: string }} b
97
+ */
98
+ function compareRouteRegistrationOrder(a, b) {
99
+ const ma = routeRegistrationMeta(a.routePath);
100
+ const mb = routeRegistrationMeta(b.routePath);
101
+ if (ma.tier !== mb.tier) return ma.tier - mb.tier;
102
+ if (ma.literalSegCount !== mb.literalSegCount) {
103
+ return mb.literalSegCount - ma.literalSegCount;
104
+ }
105
+ if (ma.depth !== mb.depth) return mb.depth - ma.depth;
106
+ if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
107
+ return ma.routePath.localeCompare(mb.routePath);
108
+ }
109
+
54
110
  /**
55
111
  * Extract HTTP method from API filename
56
112
  * @param {string} filename - Filename like health.get.js
57
113
  * @returns {{ method: string, baseName: string }}
58
114
  */
59
115
  function extractMethodFromFilename(filename) {
116
+ const hit = methodFromFilenameCache.get(filename);
117
+ if (hit !== undefined) {
118
+ return hit;
119
+ }
120
+
60
121
  const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
61
122
  const parts = filename.replace('.js', '').split('.');
62
-
123
+ let result;
124
+
63
125
  if (parts.length > 1) {
64
126
  const lastPart = parts[parts.length - 1].toLowerCase();
65
127
  if (methods.includes(lastPart)) {
66
- return {
128
+ result = {
67
129
  method: lastPart,
68
- baseName: parts.slice(0, -1).join('.')
130
+ baseName: parts.slice(0, -1).join('.'),
69
131
  };
70
132
  }
71
133
  }
72
-
73
- return { method: 'get', baseName: parts.join('.') };
134
+
135
+ if (!result) {
136
+ result = { method: 'get', baseName: parts.join('.') };
137
+ }
138
+
139
+ methodFromFilenameCache.set(filename, result);
140
+ return result;
74
141
  }
75
142
 
76
143
  /**
@@ -201,25 +268,31 @@ function createTranslator(translations) {
201
268
  */
202
269
  function loadRouteConfig(configPath, isDev) {
203
270
  if (!fs.existsSync(configPath)) {
271
+ routeConfigDevCache.delete(configPath);
204
272
  return null;
205
273
  }
206
-
274
+
207
275
  try {
208
- // Clear cache in development mode
209
- if (isDev && require.cache[require.resolve(configPath)]) {
210
- delete require.cache[require.resolve(configPath)];
276
+ if (isDev) {
277
+ const stats = fs.statSync(configPath);
278
+ const devCached = routeConfigDevCache.get(configPath);
279
+ if (devCached && devCached.mtime >= stats.mtimeMs) {
280
+ return devCached.config;
281
+ }
282
+ if (require.cache[require.resolve(configPath)]) {
283
+ delete require.cache[require.resolve(configPath)];
284
+ }
285
+ const config = require(configPath);
286
+ routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
287
+ return config;
211
288
  }
212
-
213
- if (!isDev && configCache.has(configPath)) {
289
+
290
+ if (configCache.has(configPath)) {
214
291
  return configCache.get(configPath);
215
292
  }
216
-
293
+
217
294
  const config = require(configPath);
218
-
219
- if (!isDev) {
220
- configCache.set(configPath, config);
221
- }
222
-
295
+ configCache.set(configPath, config);
223
296
  return config;
224
297
  } catch (err) {
225
298
  console.error(`Error loading route config ${configPath}:`, err.message);
@@ -431,22 +504,8 @@ function mountPages(app, options) {
431
504
  }
432
505
  }
433
506
 
434
- // Sort routes: specific routes first, then dynamic, then catch-all
435
- const sortRoutes = (routes) => {
436
- return routes.sort((a, b) => {
437
- const aHasCatchAll = a.routePath.includes('*');
438
- const bHasCatchAll = b.routePath.includes('*');
439
- const aHasDynamic = a.routePath.includes(':');
440
- const bHasDynamic = b.routePath.includes(':');
441
-
442
- if (aHasCatchAll && !bHasCatchAll) return 1;
443
- if (!aHasCatchAll && bHasCatchAll) return -1;
444
- if (aHasDynamic && !bHasDynamic) return 1;
445
- if (!aHasDynamic && bHasDynamic) return -1;
446
-
447
- return a.routePath.localeCompare(b.routePath);
448
- });
449
- };
507
+ // Sort routes: static before dynamic before catch-all; then more literal segments, then deeper paths
508
+ const sortRoutes = (routes) => routes.sort(compareRouteRegistrationOrder);
450
509
 
451
510
  // Register API routes
452
511
  for (const route of sortRoutes(apiRoutes)) {
@@ -686,6 +745,8 @@ module.exports = {
686
745
  loadI18n,
687
746
  createTranslator,
688
747
  detectLocale,
689
- resolveMiddlewares
748
+ resolveMiddlewares,
749
+ routeRegistrationMeta,
750
+ compareRouteRegistrationOrder,
690
751
  };
691
752