webspresso 0.0.89 → 0.0.90
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 +92 -45
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +12 -2
- package/core/orm/model.js +8 -2
- package/core/orm/scopes.js +2 -1
- package/package.json +1 -1
- package/plugins/admin-panel/app.js +10 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/08-record-list.js +10 -10
- package/plugins/admin-panel/index.js +41 -1
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/src/file-router.js +31 -14
- package/src/helpers.js +4 -3
- package/src/plugin-manager.js +10 -3
- package/src/server.js +184 -18
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
|
@@ -243,35 +243,59 @@
|
|
|
243
243
|
});
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
function
|
|
247
|
-
if (!htmlStr)
|
|
246
|
+
function setSanitizedHtmlContent(el, htmlStr) {
|
|
247
|
+
if (!htmlStr) {
|
|
248
|
+
el.textContent = '';
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
248
251
|
try {
|
|
249
252
|
var parser = new DOMParser();
|
|
250
253
|
var doc = parser.parseFromString(htmlStr, 'text/html');
|
|
251
|
-
var
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
var allowedTags = {
|
|
255
|
+
p: 1, br: 1, b: 1, i: 1, strong: 1, em: 1, u: 1, s: 1, strike: 1,
|
|
256
|
+
h1: 1, h2: 1, h3: 1, h4: 1, h5: 1, h6: 1, ul: 1, ol: 1, li: 1,
|
|
257
|
+
blockquote: 1, pre: 1, code: 1, a: 1, span: 1, div: 1, hr: 1,
|
|
258
|
+
table: 1, thead: 1, tbody: 1, tr: 1, th: 1, td: 1, img: 1
|
|
259
|
+
};
|
|
260
|
+
var allowedAttrs = {
|
|
261
|
+
href: 1, title: 1, target: 1, rel: 1, class: 1, id: 1, src: 1, alt: 1, width: 1, height: 1
|
|
262
|
+
};
|
|
263
|
+
var allNodes = doc.body.querySelectorAll('*');
|
|
264
|
+
for (var i = allNodes.length - 1; i >= 0; i--) {
|
|
260
265
|
var node = allNodes[i];
|
|
266
|
+
var tag = node.tagName.toLowerCase();
|
|
267
|
+
if (!allowedTags[tag]) {
|
|
268
|
+
if (node.parentNode) {
|
|
269
|
+
node.parentNode.removeChild(node);
|
|
270
|
+
} else {
|
|
271
|
+
node.remove();
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
261
275
|
var attrs = Array.prototype.slice.call(node.attributes);
|
|
262
276
|
for (var j = 0; j < attrs.length; j++) {
|
|
263
277
|
var attrName = attrs[j].name.toLowerCase();
|
|
264
|
-
var attrVal = attrs[j].value.toLowerCase().trim();
|
|
265
|
-
if (attrName.indexOf('on') === 0) {
|
|
278
|
+
var attrVal = attrs[j].value.toLowerCase().replace(/[\x00-\x20\s]+/g, '').trim();
|
|
279
|
+
if (attrName.indexOf('on') === 0 || !allowedAttrs[attrName]) {
|
|
266
280
|
node.removeAttribute(attrs[j].name);
|
|
267
|
-
} else if ((attrName === 'href' || attrName === 'src'
|
|
281
|
+
} else if ((attrName === 'href' || attrName === 'src' || attrName === 'action' || attrName === 'formaction' || attrName === 'xlink:href') &&
|
|
282
|
+
/^(?:javascript|data|vbscript):/i.test(attrVal)) {
|
|
268
283
|
node.removeAttribute(attrs[j].name);
|
|
284
|
+
} else if (tag === 'a' && node.getAttribute('target') === '_blank') {
|
|
285
|
+
node.setAttribute('rel', 'noopener noreferrer');
|
|
269
286
|
}
|
|
270
287
|
}
|
|
271
288
|
}
|
|
272
|
-
|
|
289
|
+
|
|
290
|
+
// Transfer DOM nodes directly to target element
|
|
291
|
+
while (el.firstChild) {
|
|
292
|
+
el.removeChild(el.firstChild);
|
|
293
|
+
}
|
|
294
|
+
while (doc.body.firstChild) {
|
|
295
|
+
el.appendChild(doc.body.firstChild);
|
|
296
|
+
}
|
|
273
297
|
} catch (e) {
|
|
274
|
-
|
|
298
|
+
el.textContent = htmlStr;
|
|
275
299
|
}
|
|
276
300
|
}
|
|
277
301
|
|
|
@@ -282,7 +306,7 @@
|
|
|
282
306
|
var val = data[field];
|
|
283
307
|
var isHtml = el.getAttribute('data-ws-content-html') === 'true';
|
|
284
308
|
if (isHtml) {
|
|
285
|
-
el
|
|
309
|
+
setSanitizedHtmlContent(el, val);
|
|
286
310
|
} else {
|
|
287
311
|
el.textContent = val != null ? val : '';
|
|
288
312
|
}
|
package/plugins/cors.js
CHANGED
|
@@ -47,9 +47,12 @@ function createCorsMiddleware(options = {}) {
|
|
|
47
47
|
|
|
48
48
|
function isOriginAllowed(reqOrigin, callback) {
|
|
49
49
|
if (!config.origin || config.origin === '*') {
|
|
50
|
-
|
|
50
|
+
if (config.credentials) {
|
|
51
|
+
return callback(null, false);
|
|
52
|
+
}
|
|
53
|
+
return callback(null, '*');
|
|
51
54
|
}
|
|
52
|
-
if (!reqOrigin) {
|
|
55
|
+
if (!reqOrigin || reqOrigin === 'null') {
|
|
53
56
|
return callback(null, false);
|
|
54
57
|
}
|
|
55
58
|
if (typeof config.origin === 'string') {
|
|
@@ -72,6 +75,9 @@ function createCorsMiddleware(options = {}) {
|
|
|
72
75
|
if (typeof config.origin === 'function') {
|
|
73
76
|
return config.origin(reqOrigin, callback);
|
|
74
77
|
}
|
|
78
|
+
if (config.origin === true) {
|
|
79
|
+
return callback(null, reqOrigin);
|
|
80
|
+
}
|
|
75
81
|
return callback(null, false);
|
|
76
82
|
}
|
|
77
83
|
|
|
@@ -96,15 +102,13 @@ function createCorsMiddleware(options = {}) {
|
|
|
96
102
|
}
|
|
97
103
|
|
|
98
104
|
if (allowedOrigin) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
105
|
+
res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
|
|
106
|
+
if (allowedOrigin !== '*') {
|
|
107
|
+
res.setHeader('Vary', 'Origin');
|
|
103
108
|
}
|
|
104
|
-
res.setHeader('Vary', 'Origin');
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
if (config.credentials) {
|
|
111
|
+
if (config.credentials && allowedOrigin && allowedOrigin !== '*') {
|
|
108
112
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
109
113
|
}
|
|
110
114
|
|
package/plugins/csrf/index.js
CHANGED
|
@@ -158,11 +158,11 @@ function csrfPlugin(options = {}) {
|
|
|
158
158
|
}
|
|
159
159
|
|
|
160
160
|
// 1. Retrieve or generate secret token
|
|
161
|
-
let
|
|
161
|
+
let csrfTokenValue;
|
|
162
162
|
if (useCookie) {
|
|
163
|
-
|
|
164
|
-
if (!
|
|
165
|
-
|
|
163
|
+
csrfTokenValue = cookieOptions.signed ? req.signedCookies[cookieKey] : req.cookies[cookieKey];
|
|
164
|
+
if (!csrfTokenValue) {
|
|
165
|
+
csrfTokenValue = crypto.randomBytes(32).toString('hex');
|
|
166
166
|
const isHttps = req.secure || req.headers?.['x-forwarded-proto'] === 'https' || process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true';
|
|
167
167
|
const effectiveCookieOptions = {
|
|
168
168
|
httpOnly: true,
|
|
@@ -170,18 +170,18 @@ function csrfPlugin(options = {}) {
|
|
|
170
170
|
...cookieOptions,
|
|
171
171
|
secure: cookieOpts.secure !== undefined ? cookieOpts.secure : isHttps,
|
|
172
172
|
};
|
|
173
|
-
res.cookie(cookieKey,
|
|
173
|
+
res.cookie(cookieKey, csrfTokenValue, effectiveCookieOptions);
|
|
174
174
|
}
|
|
175
175
|
} else {
|
|
176
176
|
// Session based
|
|
177
177
|
if (!req.session[sessionKey]) {
|
|
178
178
|
req.session[sessionKey] = crypto.randomBytes(32).toString('hex');
|
|
179
179
|
}
|
|
180
|
-
|
|
180
|
+
csrfTokenValue = req.session[sessionKey];
|
|
181
181
|
}
|
|
182
182
|
|
|
183
183
|
// Define standard req.csrfToken()
|
|
184
|
-
req.csrfToken = () =>
|
|
184
|
+
req.csrfToken = () => csrfTokenValue;
|
|
185
185
|
|
|
186
186
|
// 2. Perform CSRF verification for mutating HTTP methods
|
|
187
187
|
const method = req.method ? req.method.toUpperCase() : 'GET';
|
|
@@ -196,7 +196,7 @@ function csrfPlugin(options = {}) {
|
|
|
196
196
|
req.headers['x-xsrf-token']
|
|
197
197
|
);
|
|
198
198
|
|
|
199
|
-
if (!submitted || !safeCompare(submitted,
|
|
199
|
+
if (!submitted || !safeCompare(submitted, csrfTokenValue)) {
|
|
200
200
|
res.status(errorStatus);
|
|
201
201
|
if (req.accepts('html') && !req.path.startsWith('/api')) {
|
|
202
202
|
return res.send(`<h1>${errorStatus} Forbidden</h1><p>${errorMessage}</p>`);
|
package/src/file-router.js
CHANGED
|
@@ -432,16 +432,14 @@ function loadI18n(pagesDir, routeDir, locale) {
|
|
|
432
432
|
return data;
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
return function t(key, params = EMPTY_OBJECT) {
|
|
442
|
-
let value = translations[key];
|
|
435
|
+
function createTranslator(translations = {}) {
|
|
436
|
+
return function t(key, params = EMPTY_OBJECT, defaultVal = null) {
|
|
437
|
+
const defaultValue = typeof params === 'string' ? params : defaultVal;
|
|
438
|
+
const interpolationParams = typeof params === 'object' && params !== null ? params : EMPTY_OBJECT;
|
|
439
|
+
|
|
440
|
+
let value = translations ? translations[key] : undefined;
|
|
443
441
|
|
|
444
|
-
if (value === undefined) {
|
|
442
|
+
if (value === undefined && translations) {
|
|
445
443
|
// Try nested key lookup (e.g., "meta.title")
|
|
446
444
|
const parts = key.split('.');
|
|
447
445
|
value = translations;
|
|
@@ -456,12 +454,12 @@ function createTranslator(translations) {
|
|
|
456
454
|
}
|
|
457
455
|
|
|
458
456
|
if (value === undefined) {
|
|
459
|
-
return
|
|
457
|
+
return defaultValue !== null && defaultValue !== undefined ? defaultValue : key;
|
|
460
458
|
}
|
|
461
459
|
|
|
462
460
|
// Replace params like {{name}} in the translation
|
|
463
|
-
if (typeof value === 'string' &&
|
|
464
|
-
for (const [paramKey, paramValue] of Object.entries(
|
|
461
|
+
if (typeof value === 'string' && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
|
|
462
|
+
for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
|
|
465
463
|
let regex = paramRegexCache.get(paramKey);
|
|
466
464
|
if (regex === undefined) {
|
|
467
465
|
const escaped = escapeRegExp(paramKey);
|
|
@@ -814,6 +812,18 @@ function mountPages(app, options) {
|
|
|
814
812
|
sortRoutes(apiRoutes);
|
|
815
813
|
sortRoutes(ssrRoutes);
|
|
816
814
|
|
|
815
|
+
// Check for duplicate route bindings
|
|
816
|
+
const registeredRouteMap = new Map();
|
|
817
|
+
for (const r of [...apiRoutes, ...ssrRoutes]) {
|
|
818
|
+
const key = `${(r.method || 'GET').toUpperCase()} ${r.routePath}`;
|
|
819
|
+
if (registeredRouteMap.has(key)) {
|
|
820
|
+
const existing = registeredRouteMap.get(key);
|
|
821
|
+
console.warn(`[webspresso] WARNING: Duplicate route detected: ${key} in "${r.file}" conflicts with existing route in "${existing.file}".`);
|
|
822
|
+
} else {
|
|
823
|
+
registeredRouteMap.set(key, r);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
817
827
|
const apiStatic = apiRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier === 0);
|
|
818
828
|
const apiDynamic = apiRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier !== 0);
|
|
819
829
|
const ssrStatic = ssrRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier === 0);
|
|
@@ -1002,7 +1012,9 @@ function mountPages(app, options) {
|
|
|
1002
1012
|
// Run load function
|
|
1003
1013
|
if (config?.load && typeof config.load === 'function') {
|
|
1004
1014
|
const loadData = await config.load(req, ctx);
|
|
1005
|
-
|
|
1015
|
+
if (loadData && typeof loadData === 'object' && !Array.isArray(loadData)) {
|
|
1016
|
+
ctx.data = { ...ctx.data, ...loadData };
|
|
1017
|
+
}
|
|
1006
1018
|
}
|
|
1007
1019
|
|
|
1008
1020
|
// Execute hooks: afterLoad
|
|
@@ -1012,7 +1024,9 @@ function mountPages(app, options) {
|
|
|
1012
1024
|
// Run meta function
|
|
1013
1025
|
if (config?.meta && typeof config.meta === 'function') {
|
|
1014
1026
|
const metaData = await config.meta(req, ctx);
|
|
1015
|
-
|
|
1027
|
+
if (metaData && typeof metaData === 'object' && !Array.isArray(metaData)) {
|
|
1028
|
+
ctx.meta = { ...ctx.meta, ...metaData };
|
|
1029
|
+
}
|
|
1016
1030
|
}
|
|
1017
1031
|
|
|
1018
1032
|
// Execute hooks: beforeRender
|
|
@@ -1058,6 +1072,9 @@ function mountPages(app, options) {
|
|
|
1058
1072
|
await executeHook(globalHooks, 'afterRender', ctx);
|
|
1059
1073
|
await executeHook(routeHooks, 'afterRender', ctx);
|
|
1060
1074
|
|
|
1075
|
+
if (route.routePath === '/404' || route.file === '404.njk') {
|
|
1076
|
+
res.status(404);
|
|
1077
|
+
}
|
|
1061
1078
|
res.send(ctx.html);
|
|
1062
1079
|
} catch (err) {
|
|
1063
1080
|
console.error(`SSR error ${route.routePath}:`, err);
|
package/src/helpers.js
CHANGED
|
@@ -236,7 +236,7 @@ function createHelpers(ctx) {
|
|
|
236
236
|
* @returns {*}
|
|
237
237
|
*/
|
|
238
238
|
q(name, def = null) {
|
|
239
|
-
return req.query[name] !== undefined ? req.query[name] : def;
|
|
239
|
+
return req && req.query && req.query[name] !== undefined ? req.query[name] : def;
|
|
240
240
|
},
|
|
241
241
|
|
|
242
242
|
/**
|
|
@@ -246,7 +246,7 @@ function createHelpers(ctx) {
|
|
|
246
246
|
* @returns {*}
|
|
247
247
|
*/
|
|
248
248
|
param(name, def = null) {
|
|
249
|
-
return req.params[name] !== undefined ? req.params[name] : def;
|
|
249
|
+
return req && req.params && req.params[name] !== undefined ? req.params[name] : def;
|
|
250
250
|
},
|
|
251
251
|
|
|
252
252
|
/**
|
|
@@ -256,7 +256,8 @@ function createHelpers(ctx) {
|
|
|
256
256
|
* @returns {*}
|
|
257
257
|
*/
|
|
258
258
|
hdr(name, def = null) {
|
|
259
|
-
|
|
259
|
+
if (!req) return def;
|
|
260
|
+
return (typeof req.get === 'function' ? req.get(name) : (req.headers && req.headers[name.toLowerCase()])) || def;
|
|
260
261
|
},
|
|
261
262
|
|
|
262
263
|
/**
|
package/src/plugin-manager.js
CHANGED
|
@@ -202,7 +202,10 @@ class PluginManager {
|
|
|
202
202
|
continue;
|
|
203
203
|
}
|
|
204
204
|
pluginMap.set(plugin.name, plugin);
|
|
205
|
-
|
|
205
|
+
const depNames = Array.isArray(plugin.dependencies)
|
|
206
|
+
? plugin.dependencies
|
|
207
|
+
: Object.keys(plugin.dependencies || {});
|
|
208
|
+
graph.set(plugin.name, new Set(depNames));
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
// Topological sort (Kahn's algorithm)
|
|
@@ -339,7 +342,11 @@ class PluginManager {
|
|
|
339
342
|
_validateDependencies(plugin) {
|
|
340
343
|
if (!plugin.dependencies) return;
|
|
341
344
|
|
|
342
|
-
|
|
345
|
+
const depEntries = Array.isArray(plugin.dependencies)
|
|
346
|
+
? plugin.dependencies.map(d => [d, '*'])
|
|
347
|
+
: Object.entries(plugin.dependencies);
|
|
348
|
+
|
|
349
|
+
for (const [depName, versionRange] of depEntries) {
|
|
343
350
|
const dep = this.plugins.get(depName);
|
|
344
351
|
|
|
345
352
|
if (!dep) {
|
|
@@ -350,7 +357,7 @@ class PluginManager {
|
|
|
350
357
|
continue;
|
|
351
358
|
}
|
|
352
359
|
|
|
353
|
-
if (dep.version && !semver.satisfies(dep.version, versionRange)) {
|
|
360
|
+
if (dep.version && versionRange !== '*' && !semver.satisfies(dep.version, versionRange)) {
|
|
354
361
|
console.warn(
|
|
355
362
|
`[plugin-manager] Plugin "${plugin.name}" requires "${depName}@${versionRange}" ` +
|
|
356
363
|
`but found v${dep.version}. Plugin may not work correctly.`
|
package/src/server.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
* Express + Nunjucks SSR server with file-based routing
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
6
9
|
const express = require('express');
|
|
7
10
|
const helmet = require('helmet');
|
|
8
11
|
const nunjucks = require('nunjucks');
|
|
@@ -11,10 +14,103 @@ const timeout = require('connect-timeout');
|
|
|
11
14
|
const { setAppContext } = require('./app-context');
|
|
12
15
|
const { mountClientRuntime } = require('./client-runtime/mount');
|
|
13
16
|
const { resolveClientRuntime } = require('./client-runtime/resolve');
|
|
14
|
-
const { mountPages, detectLocale } = require('./file-router');
|
|
17
|
+
const { mountPages, detectLocale, loadI18n, createTranslator } = require('./file-router');
|
|
15
18
|
const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
|
|
16
19
|
const { createPluginManager } = require('./plugin-manager');
|
|
17
20
|
|
|
21
|
+
// Async storage for tracking template render call stacks (circular extends guard)
|
|
22
|
+
const renderStackStorage = new AsyncLocalStorage();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Configure Nunjucks environment with circular extends guard and loader protection
|
|
26
|
+
* @param {string|string[]} templateDirs - Template directory paths
|
|
27
|
+
* @param {Object} options - Nunjucks options
|
|
28
|
+
* @returns {nunjucks.Environment}
|
|
29
|
+
*/
|
|
30
|
+
function configureSafeNunjucks(templateDirs, options = {}) {
|
|
31
|
+
const env = nunjucks.configure(templateDirs, options);
|
|
32
|
+
const origGetTemplate = env.getTemplate.bind(env);
|
|
33
|
+
const origRender = env.render.bind(env);
|
|
34
|
+
const origRenderString = env.renderString.bind(env);
|
|
35
|
+
|
|
36
|
+
function wrapTemplateRoot(tmpl, name, store) {
|
|
37
|
+
if (!tmpl || tmpl._safeWrapped) return;
|
|
38
|
+
tmpl._safeWrapped = true;
|
|
39
|
+
const origRoot = tmpl.rootRenderFunc;
|
|
40
|
+
if (typeof origRoot !== 'function') return;
|
|
41
|
+
|
|
42
|
+
tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
|
|
43
|
+
const normalizedName = String(name || tmpl.path || 'anonymous');
|
|
44
|
+
store.stack.push(normalizedName);
|
|
45
|
+
let finished = false;
|
|
46
|
+
const done = (err, out) => {
|
|
47
|
+
if (!finished) {
|
|
48
|
+
finished = true;
|
|
49
|
+
const idx = store.stack.lastIndexOf(normalizedName);
|
|
50
|
+
if (idx !== -1) store.stack.splice(idx, 1);
|
|
51
|
+
}
|
|
52
|
+
renderCb(err, out);
|
|
53
|
+
};
|
|
54
|
+
try {
|
|
55
|
+
return origRoot.call(this, e, context, frame, runtime, done);
|
|
56
|
+
} catch (ex) {
|
|
57
|
+
const idx = store.stack.lastIndexOf(normalizedName);
|
|
58
|
+
if (idx !== -1) store.stack.splice(idx, 1);
|
|
59
|
+
throw ex;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
env.getTemplate = function(name, eagerCompile, parentName, ignoreMissing, cb) {
|
|
65
|
+
if (typeof parentName === 'function') {
|
|
66
|
+
cb = parentName;
|
|
67
|
+
parentName = null;
|
|
68
|
+
}
|
|
69
|
+
if (typeof eagerCompile === 'function') {
|
|
70
|
+
cb = eagerCompile;
|
|
71
|
+
eagerCompile = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const store = renderStackStorage.getStore();
|
|
75
|
+
if (store && name) {
|
|
76
|
+
const normalizedName = String(name);
|
|
77
|
+
if (store.stack.includes(normalizedName)) {
|
|
78
|
+
const cycle = [...store.stack, normalizedName].join(' -> ');
|
|
79
|
+
const err = new Error(`Circular template extension detected: ${cycle}`);
|
|
80
|
+
if (typeof cb === 'function') return cb(err);
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
|
|
86
|
+
if (tmpl && store) {
|
|
87
|
+
wrapTemplateRoot(tmpl, name, store);
|
|
88
|
+
}
|
|
89
|
+
cb(err, tmpl);
|
|
90
|
+
} : undefined;
|
|
91
|
+
|
|
92
|
+
const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
|
|
93
|
+
if (res && store) {
|
|
94
|
+
wrapTemplateRoot(res, name, store);
|
|
95
|
+
}
|
|
96
|
+
return res;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
env.render = function(name, ctx, cb) {
|
|
100
|
+
return renderStackStorage.run({ stack: [] }, () => {
|
|
101
|
+
return origRender(name, ctx, cb);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
env.renderString = function(src, ctx, opts, cb) {
|
|
106
|
+
return renderStackStorage.run({ stack: [] }, () => {
|
|
107
|
+
return origRenderString(src, ctx, opts, cb);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return env;
|
|
112
|
+
}
|
|
113
|
+
|
|
18
114
|
/**
|
|
19
115
|
* Get default Helmet configuration
|
|
20
116
|
* @param {boolean} isDev - Whether in development mode
|
|
@@ -58,13 +154,27 @@ function getDefaultHelmetConfig(isDev) {
|
|
|
58
154
|
}
|
|
59
155
|
|
|
60
156
|
/**
|
|
61
|
-
* Use JSON error responses for `pages/api/*` routes and clients that
|
|
157
|
+
* Use JSON error responses for `pages/api/*` routes, XHR requests, and clients that prefer JSON.
|
|
62
158
|
* @param {import('express').Request} req
|
|
63
159
|
* @returns {boolean}
|
|
64
160
|
*/
|
|
65
161
|
function preferJsonErrorResponse(req) {
|
|
162
|
+
if (!req) return false;
|
|
66
163
|
if (req.path && req.path.startsWith('/api')) return true;
|
|
67
|
-
|
|
164
|
+
if (req.xhr) return true;
|
|
165
|
+
|
|
166
|
+
if (typeof req.accepts === 'function') {
|
|
167
|
+
const preferred = req.accepts(['html', 'json']);
|
|
168
|
+
if (preferred === 'json') return true;
|
|
169
|
+
if (preferred === 'html') return false;
|
|
170
|
+
return !req.accepts('html');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const accept = req.headers && req.headers.accept;
|
|
174
|
+
if (accept && accept.includes('application/json') && !accept.includes('text/html')) {
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
68
178
|
}
|
|
69
179
|
|
|
70
180
|
/**
|
|
@@ -405,10 +515,10 @@ function createApp(options = {}) {
|
|
|
405
515
|
|
|
406
516
|
mountClientRuntime(app, clientRuntime);
|
|
407
517
|
|
|
408
|
-
// Configure Nunjucks
|
|
409
|
-
const templateDirs = viewsDir ? [
|
|
518
|
+
// Configure Nunjucks with viewsDir priority and circular extension guard
|
|
519
|
+
const templateDirs = viewsDir ? [viewsDir, pagesDir] : [pagesDir];
|
|
410
520
|
|
|
411
|
-
const nunjucksEnv =
|
|
521
|
+
const nunjucksEnv = configureSafeNunjucks(templateDirs, {
|
|
412
522
|
autoescape: true,
|
|
413
523
|
express: app,
|
|
414
524
|
watch: isDev && !isTest,
|
|
@@ -528,10 +638,15 @@ function createApp(options = {}) {
|
|
|
528
638
|
// Merge plugin helpers
|
|
529
639
|
const pluginHelpers = pluginManager.getHelpers();
|
|
530
640
|
Object.assign(fsy, pluginHelpers);
|
|
641
|
+
|
|
642
|
+
// Provide safe i18n translator helper
|
|
643
|
+
const translations = loadI18n(pagesDir, '', locale);
|
|
644
|
+
const t = createTranslator(translations);
|
|
531
645
|
|
|
532
646
|
return {
|
|
533
647
|
fsy,
|
|
534
648
|
locale,
|
|
649
|
+
t,
|
|
535
650
|
isDev,
|
|
536
651
|
url: req.url,
|
|
537
652
|
method: req.method,
|
|
@@ -539,20 +654,63 @@ function createApp(options = {}) {
|
|
|
539
654
|
};
|
|
540
655
|
}
|
|
541
656
|
|
|
657
|
+
// Pre-resolve error templates and loaders once at startup to avoid runtime synchronous fs operations
|
|
658
|
+
const resolved404Template = typeof errorPages.notFound === 'string'
|
|
659
|
+
? errorPages.notFound
|
|
660
|
+
: ((viewsDir && fs.existsSync(path.join(viewsDir, '404.njk'))) || fs.existsSync(path.join(pagesDir, '404.njk')))
|
|
661
|
+
? '404.njk'
|
|
662
|
+
: null;
|
|
663
|
+
|
|
664
|
+
const config404Path = path.join(pagesDir, '404.js');
|
|
665
|
+
const has404DataLoader = fs.existsSync(config404Path);
|
|
666
|
+
|
|
667
|
+
const resolved500Template = typeof errorPages.serverError === 'string'
|
|
668
|
+
? errorPages.serverError
|
|
669
|
+
: ((viewsDir && fs.existsSync(path.join(viewsDir, '500.njk'))) || fs.existsSync(path.join(pagesDir, '500.njk')))
|
|
670
|
+
? '500.njk'
|
|
671
|
+
: null;
|
|
672
|
+
|
|
673
|
+
const resolved503Template = typeof errorPages.timeout === 'string'
|
|
674
|
+
? errorPages.timeout
|
|
675
|
+
: ((viewsDir && fs.existsSync(path.join(viewsDir, '503.njk'))) || fs.existsSync(path.join(pagesDir, '503.njk')))
|
|
676
|
+
? '503.njk'
|
|
677
|
+
: null;
|
|
678
|
+
|
|
542
679
|
// 404 handler
|
|
543
|
-
app.use((req, res) => {
|
|
680
|
+
app.use(async (req, res) => {
|
|
544
681
|
res.status(404);
|
|
545
682
|
const ctx = createErrorContext(req);
|
|
546
683
|
|
|
547
|
-
// Custom handler function
|
|
684
|
+
// 1. Custom handler function
|
|
548
685
|
if (typeof errorPages.notFound === 'function') {
|
|
549
686
|
return errorPages.notFound(req, res, ctx);
|
|
550
687
|
}
|
|
551
688
|
|
|
552
|
-
// Custom template
|
|
553
|
-
|
|
689
|
+
// 2. Custom or auto-discovered 404 template (pre-resolved at startup)
|
|
690
|
+
const notFoundTemplate = resolved404Template;
|
|
691
|
+
|
|
692
|
+
if (notFoundTemplate && !preferJsonErrorResponse(req)) {
|
|
554
693
|
try {
|
|
555
|
-
|
|
694
|
+
// If pages/404.js exists, execute load() data loader
|
|
695
|
+
if (has404DataLoader) {
|
|
696
|
+
try {
|
|
697
|
+
if (isDev && require.cache[require.resolve(config404Path)]) {
|
|
698
|
+
delete require.cache[require.resolve(config404Path)];
|
|
699
|
+
}
|
|
700
|
+
const config404 = require(config404Path);
|
|
701
|
+
const loadFn = typeof config404 === 'function' ? config404 : config404.load;
|
|
702
|
+
if (typeof loadFn === 'function') {
|
|
703
|
+
const loadData = await loadFn({ req, res, db: ctx.db || null, ctx });
|
|
704
|
+
if (loadData && typeof loadData === 'object') {
|
|
705
|
+
Object.assign(ctx, loadData);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
} catch (loadErr) {
|
|
709
|
+
console.error('Error executing 404.js load():', loadErr);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const html = nunjucksEnv.render(notFoundTemplate, ctx);
|
|
556
714
|
return res.send(html);
|
|
557
715
|
} catch (e) {
|
|
558
716
|
console.error('Error rendering 404 template:', e);
|
|
@@ -560,7 +718,7 @@ function createApp(options = {}) {
|
|
|
560
718
|
}
|
|
561
719
|
|
|
562
720
|
// Default response
|
|
563
|
-
if (req
|
|
721
|
+
if (!preferJsonErrorResponse(req)) {
|
|
564
722
|
res.send(default404Html());
|
|
565
723
|
} else {
|
|
566
724
|
res.json({ error: 'Not Found', status: 404 });
|
|
@@ -569,6 +727,10 @@ function createApp(options = {}) {
|
|
|
569
727
|
|
|
570
728
|
// Error handler
|
|
571
729
|
app.use((err, req, res, next) => {
|
|
730
|
+
if (res.headersSent) {
|
|
731
|
+
return next(err);
|
|
732
|
+
}
|
|
733
|
+
|
|
572
734
|
// Handle timeout errors
|
|
573
735
|
if (req.timedout) {
|
|
574
736
|
console.error('Request timed out:', req.method, req.url);
|
|
@@ -580,10 +742,12 @@ function createApp(options = {}) {
|
|
|
580
742
|
return errorPages.timeout(req, res, ctx);
|
|
581
743
|
}
|
|
582
744
|
|
|
583
|
-
// Custom timeout template
|
|
584
|
-
|
|
745
|
+
// Custom or auto-discovered timeout template (pre-resolved at startup)
|
|
746
|
+
const timeoutTemplate = resolved503Template;
|
|
747
|
+
|
|
748
|
+
if (timeoutTemplate && !preferJsonErrorResponse(req)) {
|
|
585
749
|
try {
|
|
586
|
-
const html = nunjucksEnv.render(
|
|
750
|
+
const html = nunjucksEnv.render(timeoutTemplate, ctx);
|
|
587
751
|
return res.send(html);
|
|
588
752
|
} catch (e) {
|
|
589
753
|
console.error('Error rendering timeout template:', e);
|
|
@@ -610,10 +774,12 @@ function createApp(options = {}) {
|
|
|
610
774
|
return errorPages.serverError(err, req, res, ctx);
|
|
611
775
|
}
|
|
612
776
|
|
|
613
|
-
// Custom
|
|
614
|
-
|
|
777
|
+
// Custom or auto-discovered 500 template (pre-resolved at startup)
|
|
778
|
+
const serverErrorTemplate = resolved500Template;
|
|
779
|
+
|
|
780
|
+
if (serverErrorTemplate && !preferJsonErrorResponse(req)) {
|
|
615
781
|
try {
|
|
616
|
-
const html = nunjucksEnv.render(
|
|
782
|
+
const html = nunjucksEnv.render(serverErrorTemplate, ctx);
|
|
617
783
|
return res.send(html);
|
|
618
784
|
} catch (e) {
|
|
619
785
|
console.error('Error rendering 500 template:', e);
|
|
@@ -53,7 +53,7 @@ project/
|
|
|
53
53
|
| `db` | DB instance from `createDatabase()` → **`ctx.db`** in `load`, `meta`, plugins |
|
|
54
54
|
| `middlewares` | Named map; reference by string in route/API config (`middleware: ['auth']`) |
|
|
55
55
|
| `plugins` | Array of plugin factories/objects |
|
|
56
|
-
| `errorPages` | `{ notFound, serverError, timeout }` —
|
|
56
|
+
| `errorPages` | `{ notFound?: string \| Function, serverError?: string \| Function, timeout?: string \| Function }` — Nunjucks template path (e.g. `notFound: '404.njk'`, relative to `viewsDir`) or Express handler fn `(req, res, ctx) => ...`. Template context receives `{ fsy, locale, isDev, url, method }` (and `{ error, status }` for 500). File-based SSR/API errors are passed to this Express error middleware via `next(err)`. **`serverError` / `timeout` as a template path** is not used for paths under **`/api`** (those get default JSON). |
|
|
57
57
|
| `timeout` | e.g. `'30s'` or `false` |
|
|
58
58
|
| `helmet` | `true` / `false` / object |
|
|
59
59
|
| `assets` | `{ version, manifestPath, prefix }` for `fsy.asset` / `fsy.css` / `fsy.js` |
|
|
@@ -160,7 +160,7 @@ Available in all Nunjucks templates:
|
|
|
160
160
|
- **Request:** `fsy.q`, `fsy.param`, `fsy.hdr`
|
|
161
161
|
- **Utils:** `fsy.slugify`, `fsy.truncate`, `fsy.prettyBytes`, `fsy.prettyMs`
|
|
162
162
|
- **Dates:** `fsy.date`, `fsy.dateFormat`, `fsy.dateFromNow`, `fsy.dateDiff`, …
|
|
163
|
-
- **Assets:** `fsy.asset
|
|
163
|
+
- **Assets & Versioning:** `fsy.asset(path)` (returns versioned `?v=...` or Vite/Webpack manifest resolved URL), `fsy.css(href, attrs)` (generates `<link>` tag), `fsy.js(src, attrs)` (generates `<script>` tag with boolean attributes), `fsy.img(src, alt, attrs)` (generates `<img>` tag). Configured via `createApp({ assets: { version, manifestPath, prefix } })`.
|
|
164
164
|
- **Dev:** `fsy.isDev()`
|
|
165
165
|
- **SEO:** `fsy.jsonld`
|
|
166
166
|
|