webspresso 0.0.66 → 0.0.68

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
+ };
@@ -0,0 +1,34 @@
1
+ /* global Swup, SwupHeadPlugin, SwupScriptsPlugin, Alpine */
2
+ (function () {
3
+ if (typeof Swup === 'undefined' || typeof SwupHeadPlugin === 'undefined' || typeof SwupScriptsPlugin === 'undefined' || typeof Alpine === 'undefined') {
4
+ return;
5
+ }
6
+
7
+ function ignoreVisit(url, ctx) {
8
+ var el = ctx && ctx.el;
9
+ if (el && el.closest && el.closest('[data-no-swup]')) return true;
10
+ try {
11
+ var u = new URL(url, window.location.origin);
12
+ var p = u.pathname;
13
+ if (p.indexOf('/_admin') === 0 || p.indexOf('/_webspresso') === 0) return true;
14
+ } catch (e) {
15
+ /* ignore */
16
+ }
17
+ return false;
18
+ }
19
+
20
+ var swup = new Swup({
21
+ containers: ['#swup'],
22
+ plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
23
+ ignoreVisit: ignoreVisit,
24
+ });
25
+
26
+ swup.hooks.on('content:replace', function () {
27
+ var root = document.querySelector('#swup');
28
+ if (root && typeof Alpine.initTree === 'function') {
29
+ Alpine.initTree(root);
30
+ }
31
+ });
32
+
33
+ window.__webspressoSwup = swup;
34
+ })();
@@ -0,0 +1,26 @@
1
+ /* global Swup, SwupHeadPlugin, SwupScriptsPlugin */
2
+ (function () {
3
+ if (typeof Swup === 'undefined' || typeof SwupHeadPlugin === 'undefined' || typeof SwupScriptsPlugin === 'undefined') {
4
+ return;
5
+ }
6
+
7
+ function ignoreVisit(url, ctx) {
8
+ var el = ctx && ctx.el;
9
+ if (el && el.closest && el.closest('[data-no-swup]')) return true;
10
+ try {
11
+ var u = new URL(url, window.location.origin);
12
+ var p = u.pathname;
13
+ if (p.indexOf('/_admin') === 0 || p.indexOf('/_webspresso') === 0) return true;
14
+ } catch (e) {
15
+ /* ignore */
16
+ }
17
+ return false;
18
+ }
19
+
20
+ var swup = new Swup({
21
+ containers: ['#swup'],
22
+ plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
23
+ ignoreVisit: ignoreVisit,
24
+ });
25
+ window.__webspressoSwup = swup;
26
+ })();
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Mount Express routes that serve vendored Alpine / Swup UMD builds from node_modules
3
+ * and framework bootstrap scripts from src/client-runtime/.
4
+ */
5
+
6
+ const path = require('path');
7
+ const express = require('express');
8
+
9
+ const CLIENT_RUNTIME_BASE = '/__webspresso/client-runtime';
10
+
11
+ /** Resolve a file next to the package's resolved main entry (works with package "exports"). */
12
+ function pkgFileFromMain(pkg, ...segments) {
13
+ const entry = require.resolve(pkg);
14
+ return path.join(path.dirname(entry), ...segments);
15
+ }
16
+
17
+ /**
18
+ * @param {import('express').Express} app
19
+ * @param {{ alpine: boolean, swup: boolean }} flags
20
+ */
21
+ function mountClientRuntime(app, flags) {
22
+ if (!flags || (!flags.alpine && !flags.swup)) return;
23
+
24
+ const router = express.Router();
25
+
26
+ function send(res, filePath) {
27
+ res.type('application/javascript');
28
+ res.sendFile(filePath);
29
+ }
30
+
31
+ if (flags.alpine) {
32
+ router.get('/alpine.min.js', (req, res) => {
33
+ send(res, pkgFileFromMain('alpinejs', 'cdn.min.js'));
34
+ });
35
+ }
36
+
37
+ if (flags.swup) {
38
+ router.get('/swup.umd.js', (req, res) => {
39
+ send(res, pkgFileFromMain('swup', 'Swup.umd.js'));
40
+ });
41
+ router.get('/swup-head-plugin.umd.js', (req, res) => {
42
+ send(res, pkgFileFromMain('@swup/head-plugin', 'index.umd.js'));
43
+ });
44
+ router.get('/swup-scripts-plugin.umd.js', (req, res) => {
45
+ send(res, pkgFileFromMain('@swup/scripts-plugin', 'index.umd.js'));
46
+ });
47
+ const runtimeDir = __dirname;
48
+ if (flags.alpine) {
49
+ router.get('/bootstrap-alpine-swup.js', (req, res) => {
50
+ send(res, path.join(runtimeDir, 'bootstrap-alpine-swup.js'));
51
+ });
52
+ } else {
53
+ router.get('/bootstrap-swup.js', (req, res) => {
54
+ send(res, path.join(runtimeDir, 'bootstrap-swup.js'));
55
+ });
56
+ }
57
+ }
58
+
59
+ app.use(CLIENT_RUNTIME_BASE, router);
60
+ }
61
+
62
+ module.exports = {
63
+ mountClientRuntime,
64
+ CLIENT_RUNTIME_BASE,
65
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Resolve clientRuntime flags from createApp({ clientRuntime }) and optional env overrides.
3
+ * Env: WEBSPRESSO_ALPINE=1|true, WEBSPRESSO_SWUP=1|true (override explicit false from options when set).
4
+ */
5
+
6
+ function envTruthy(name) {
7
+ const v = process.env[name];
8
+ if (v == null || v === '') return undefined;
9
+ return v === '1' || /^true$/i.test(String(v));
10
+ }
11
+
12
+ function optionEnabled(v) {
13
+ if (v === true) return true;
14
+ if (v && typeof v === 'object') return true;
15
+ return false;
16
+ }
17
+
18
+ /**
19
+ * @param {object} [options]
20
+ * @param {object} [options.clientRuntime]
21
+ * @param {boolean|object} [options.clientRuntime.alpine]
22
+ * @param {boolean|object} [options.clientRuntime.swup]
23
+ * @returns {{ alpine: boolean, swup: boolean }}
24
+ */
25
+ function resolveClientRuntime(options = {}) {
26
+ const cr = options.clientRuntime;
27
+ let alpine = false;
28
+ let swup = false;
29
+ if (cr && typeof cr === 'object') {
30
+ alpine = optionEnabled(cr.alpine);
31
+ swup = optionEnabled(cr.swup);
32
+ }
33
+ const envA = envTruthy('WEBSPRESSO_ALPINE');
34
+ const envS = envTruthy('WEBSPRESSO_SWUP');
35
+ if (envA !== undefined) alpine = envA;
36
+ if (envS !== undefined) swup = envS;
37
+ return { alpine, swup };
38
+ }
39
+
40
+ module.exports = { resolveClientRuntime };
@@ -365,10 +365,22 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
365
365
  * @param {Object} options.pluginManager - Plugin manager instance
366
366
  * @param {boolean} options.silent - Suppress console output
367
367
  * @param {Object} options.db - Database instance (exposed as ctx.db in load/meta)
368
+ * @param {{ alpine?: boolean, swup?: boolean }} [options.clientRuntime] - Passed to Nunjucks as `clientRuntime` (default both false)
368
369
  * @returns {Array} Route metadata for plugins
369
370
  */
370
371
  function mountPages(app, options) {
371
- const { pagesDir, nunjucks, middlewares = {}, pluginManager = null, silent = false, db = null } = options;
372
+ const {
373
+ pagesDir,
374
+ nunjucks,
375
+ middlewares = {},
376
+ pluginManager = null,
377
+ silent = false,
378
+ db = null,
379
+ clientRuntime: clientRuntimeOpt = null,
380
+ } = options;
381
+ const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
382
+ ? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
383
+ : { alpine: false, swup: false };
372
384
  const isDev = process.env.NODE_ENV !== 'production';
373
385
  const log = silent ? () => {} : console.log.bind(console);
374
386
 
@@ -548,7 +560,8 @@ function mountPages(app, options) {
548
560
  indexable: true,
549
561
  canonical: null
550
562
  },
551
- fsy: { ...baseHelpers, ...pluginHelpers }
563
+ fsy: { ...baseHelpers, ...pluginHelpers },
564
+ clientRuntime,
552
565
  };
553
566
 
554
567
  // Execute hooks: onRequest
@@ -612,6 +625,7 @@ function mountPages(app, options) {
612
625
  locale: ctx.locale,
613
626
  t: ctx.t,
614
627
  fsy: ctx.fsy,
628
+ clientRuntime: ctx.clientRuntime,
615
629
  req: {
616
630
  path: req.path,
617
631
  query: req.query,
package/src/server.js CHANGED
@@ -9,6 +9,8 @@ const nunjucks = require('nunjucks');
9
9
  const timeout = require('connect-timeout');
10
10
 
11
11
  const { setAppContext } = require('./app-context');
12
+ const { mountClientRuntime } = require('./client-runtime/mount');
13
+ const { resolveClientRuntime } = require('./client-runtime/resolve');
12
14
  const { mountPages } = require('./file-router');
13
15
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
14
16
  const { createPluginManager } = require('./plugin-manager');
@@ -258,6 +260,7 @@ function haltOnTimedout(req, res, next) {
258
260
  * @param {string|boolean} options.timeout - Request timeout (default: '30s', false to disable)
259
261
  * @param {Object} options.auth - Authentication manager instance (from createAuth)
260
262
  * @param {Object} options.db - Database instance (exposed as ctx.db to plugins)
263
+ * @param {Object} [options.clientRuntime] - Optional client assets: `{ alpine?: boolean|object, swup?: boolean|object }`. Overridable by env `WEBSPRESSO_ALPINE` / `WEBSPRESSO_SWUP` (=1 or true). Serves `/__webspresso/client-runtime/*` when either flag is on.
261
264
  * @param {function(import('express').Express, Object): void} [options.setupRoutes] - Called after file routes and plugins, before 404 handler
262
265
  * @returns {Object} { app, nunjucksEnv, pluginManager, authMiddleware }
263
266
  */
@@ -294,6 +297,8 @@ function createApp(options = {}) {
294
297
  throw new Error('pagesDir is required');
295
298
  }
296
299
 
300
+ const clientRuntime = resolveClientRuntime(options);
301
+
297
302
  setAppContext({ db: options.db ?? null });
298
303
 
299
304
  const app = express();
@@ -378,7 +383,9 @@ function createApp(options = {}) {
378
383
  etag: true
379
384
  }));
380
385
  }
381
-
386
+
387
+ mountClientRuntime(app, clientRuntime);
388
+
382
389
  // Configure Nunjucks
383
390
  const templateDirs = viewsDir ? [pagesDir, viewsDir] : [pagesDir];
384
391
 
@@ -439,7 +446,8 @@ function createApp(options = {}) {
439
446
  middlewares,
440
447
  pluginManager,
441
448
  silent: isTest,
442
- db: options.db ?? null
449
+ db: options.db ?? null,
450
+ clientRuntime,
443
451
  });
444
452
 
445
453
  // Set route metadata in plugin manager
@@ -480,6 +488,7 @@ function createApp(options = {}) {
480
488
  authMiddleware,
481
489
  pluginManager,
482
490
  options,
491
+ clientRuntime,
483
492
  });
484
493
  }
485
494