vako 1.3.0
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/CHANGELOG.md +63 -0
- package/README.md +1944 -0
- package/bin/commands/quick-setup.js +111 -0
- package/bin/commands/setup-executor.js +203 -0
- package/bin/commands/setup.js +737 -0
- package/bin/create-veko-app.js +75 -0
- package/bin/veko-update.js +205 -0
- package/bin/veko.js +188 -0
- package/error/error.ejs +382 -0
- package/index.js +36 -0
- package/lib/adapters/nextjs-adapter.js +241 -0
- package/lib/app.js +749 -0
- package/lib/core/auth-manager.js +1353 -0
- package/lib/core/auto-updater.js +1118 -0
- package/lib/core/logger.js +97 -0
- package/lib/core/module-installer.js +86 -0
- package/lib/dev/dev-server.js +292 -0
- package/lib/layout/layout-manager.js +834 -0
- package/lib/plugin-manager.js +1795 -0
- package/lib/routing/route-manager.js +1000 -0
- package/package.json +231 -0
- package/templates/public/css/style.css +2 -0
- package/templates/public/js/main.js +1 -0
- package/tsconfig.json +50 -0
- package/types/index.d.ts +238 -0
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
class LayoutManager {
|
|
5
|
+
constructor(app, options = {}) {
|
|
6
|
+
this.app = app;
|
|
7
|
+
this.options = {
|
|
8
|
+
defaultLayout: 'default',
|
|
9
|
+
layoutsDir: 'views/layouts',
|
|
10
|
+
extension: '.ejs',
|
|
11
|
+
...options
|
|
12
|
+
};
|
|
13
|
+
this.layoutCache = new Map();
|
|
14
|
+
this.layoutSections = new Map();
|
|
15
|
+
|
|
16
|
+
// Configuration de sécurité
|
|
17
|
+
this.securityConfig = {
|
|
18
|
+
maxCacheSize: 1000,
|
|
19
|
+
maxSectionSize: 50000, // 50KB par section
|
|
20
|
+
allowedExtensions: ['.ejs', '.html'],
|
|
21
|
+
maxNestingDepth: 10
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Initialisation du nettoyage automatique du cache
|
|
25
|
+
this.initCacheCleanup();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Middleware principal pour la gestion des layouts
|
|
30
|
+
*/
|
|
31
|
+
middleware() {
|
|
32
|
+
return (req, res, next) => {
|
|
33
|
+
const originalRender = res.render;
|
|
34
|
+
|
|
35
|
+
// Wrapper sécurisé pour res.render
|
|
36
|
+
res.render = this.createSecureRenderWrapper(originalRender, req, res);
|
|
37
|
+
|
|
38
|
+
// Helpers de layout
|
|
39
|
+
res.locals.layout = this.createLayoutHelpers(req, res);
|
|
40
|
+
|
|
41
|
+
next();
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Créer un wrapper sécurisé pour res.render
|
|
47
|
+
*/
|
|
48
|
+
createSecureRenderWrapper(originalRender, req, res) {
|
|
49
|
+
return (view, options = {}, callback) => {
|
|
50
|
+
try {
|
|
51
|
+
// Validation des paramètres
|
|
52
|
+
this.validateRenderParameters(view, options);
|
|
53
|
+
|
|
54
|
+
// Bypass du layout si demandé
|
|
55
|
+
if (options.layout === false) {
|
|
56
|
+
return originalRender.call(res, view, options, callback);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const layoutName = this.sanitizeLayoutName(options.layout || this.options.defaultLayout);
|
|
60
|
+
const layoutData = this.prepareLayoutData(view, options, req);
|
|
61
|
+
|
|
62
|
+
this.renderWithLayout(res, view, layoutName, layoutData, originalRender, callback);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
this.handleRenderError(error, res, view, options, originalRender, callback);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validation sécurisée des paramètres de rendu
|
|
71
|
+
*/
|
|
72
|
+
validateRenderParameters(view, options) {
|
|
73
|
+
if (!view || typeof view !== 'string') {
|
|
74
|
+
throw new Error('View name must be a non-empty string');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (view.length > 255) {
|
|
78
|
+
throw new Error('View name too long');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validation contre la traversée de répertoire
|
|
82
|
+
if (view.includes('..') || view.includes('\\') || view.startsWith('/')) {
|
|
83
|
+
throw new Error('Invalid view name: path traversal detected');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options && typeof options !== 'object') {
|
|
87
|
+
throw new Error('Options must be an object');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Nettoyage sécurisé du nom de layout
|
|
93
|
+
*/
|
|
94
|
+
sanitizeLayoutName(layoutName) {
|
|
95
|
+
if (!layoutName || typeof layoutName !== 'string') {
|
|
96
|
+
return this.options.defaultLayout;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Supprimer les caractères dangereux
|
|
100
|
+
const sanitized = layoutName
|
|
101
|
+
.replace(/[^a-zA-Z0-9\-_]/g, '')
|
|
102
|
+
.substring(0, 50);
|
|
103
|
+
|
|
104
|
+
return sanitized || this.options.defaultLayout;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Préparation sécurisée des données de layout
|
|
109
|
+
*/
|
|
110
|
+
prepareLayoutData(view, options, req) {
|
|
111
|
+
const baseData = {
|
|
112
|
+
view: this.sanitizeViewName(view),
|
|
113
|
+
sections: this.sanitizeSections(options.sections || {}),
|
|
114
|
+
meta: this.sanitizeMetaData(options.meta),
|
|
115
|
+
layout: this.sanitizeLayoutOptions(options.layout),
|
|
116
|
+
request: this.extractSafeRequestData(req)
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Fusion sécurisée des options
|
|
120
|
+
return this.mergeOptionsSecurely(baseData, options);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Nettoyage des sections
|
|
125
|
+
*/
|
|
126
|
+
sanitizeSections(sections) {
|
|
127
|
+
const sanitized = {};
|
|
128
|
+
|
|
129
|
+
for (const [key, value] of Object.entries(sections)) {
|
|
130
|
+
if (typeof key === 'string' && key.length <= 50) {
|
|
131
|
+
const cleanKey = key.replace(/[^a-zA-Z0-9\-_]/g, '');
|
|
132
|
+
|
|
133
|
+
if (typeof value === 'string' && value.length <= this.securityConfig.maxSectionSize) {
|
|
134
|
+
sanitized[cleanKey] = value;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return sanitized;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Nettoyage des métadonnées
|
|
144
|
+
*/
|
|
145
|
+
sanitizeMetaData(meta = {}) {
|
|
146
|
+
const allowedFields = ['title', 'description', 'keywords', 'author', 'viewport'];
|
|
147
|
+
const sanitized = {};
|
|
148
|
+
|
|
149
|
+
allowedFields.forEach(field => {
|
|
150
|
+
if (meta[field] && typeof meta[field] === 'string') {
|
|
151
|
+
sanitized[field] = meta[field].substring(0, 200);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
title: sanitized.title || 'Veko.js App',
|
|
157
|
+
description: sanitized.description || '',
|
|
158
|
+
keywords: sanitized.keywords || '',
|
|
159
|
+
author: sanitized.author || '',
|
|
160
|
+
viewport: sanitized.viewport || 'width=device-width, initial-scale=1.0'
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Extraction sécurisée des données de requête
|
|
166
|
+
*/
|
|
167
|
+
extractSafeRequestData(req) {
|
|
168
|
+
return {
|
|
169
|
+
url: req.url || '',
|
|
170
|
+
path: req.path || '',
|
|
171
|
+
method: req.method || 'GET',
|
|
172
|
+
query: this.sanitizeQueryParams(req.query),
|
|
173
|
+
params: this.sanitizeParams(req.params)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rendu avec layout de manière asynchrone et sécurisée
|
|
179
|
+
*/
|
|
180
|
+
async renderWithLayout(res, view, layoutName, data, originalRender, callback) {
|
|
181
|
+
try {
|
|
182
|
+
// Vérification du cache
|
|
183
|
+
const cacheKey = `${view}:${layoutName}`;
|
|
184
|
+
|
|
185
|
+
// Rendu de la vue en contenu
|
|
186
|
+
const content = await this.renderViewToString(view, data);
|
|
187
|
+
data.sections.content = content;
|
|
188
|
+
|
|
189
|
+
const layoutPath = this.getLayoutPath(layoutName);
|
|
190
|
+
|
|
191
|
+
if (await this.layoutExists(layoutPath)) {
|
|
192
|
+
originalRender.call(res, layoutPath, data, callback);
|
|
193
|
+
} else {
|
|
194
|
+
await this.createDefaultLayoutAsync(layoutName);
|
|
195
|
+
originalRender.call(res, layoutPath, data, callback);
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
this.handleRenderError(error, res, view, data, originalRender, callback);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Rendu de vue en chaîne de caractères avec gestion d'erreur
|
|
204
|
+
*/
|
|
205
|
+
async renderViewToString(view, data) {
|
|
206
|
+
return new Promise((resolve, reject) => {
|
|
207
|
+
const viewPath = this.resolveViewPath(view);
|
|
208
|
+
|
|
209
|
+
// Vérification sécurisée de l'existence du fichier
|
|
210
|
+
this.validateViewPath(viewPath)
|
|
211
|
+
.then(() => {
|
|
212
|
+
try {
|
|
213
|
+
const ejs = require('ejs');
|
|
214
|
+
const template = fs.readFileSync(viewPath, 'utf8');
|
|
215
|
+
|
|
216
|
+
// Options sécurisées pour EJS
|
|
217
|
+
const ejsOptions = {
|
|
218
|
+
filename: viewPath,
|
|
219
|
+
rmWhitespace: true,
|
|
220
|
+
escape: (str) => this.escapeHtml(str)
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const html = ejs.render(template, data, ejsOptions);
|
|
224
|
+
resolve(html);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
reject(new Error(`Template rendering failed: ${error.message}`));
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
.catch(reject);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validation asynchrone du chemin de vue
|
|
235
|
+
*/
|
|
236
|
+
async validateViewPath(viewPath) {
|
|
237
|
+
return new Promise((resolve, reject) => {
|
|
238
|
+
fs.access(viewPath, fs.constants.R_OK, (err) => {
|
|
239
|
+
if (err) {
|
|
240
|
+
reject(new Error(`View not accessible: ${path.basename(viewPath)}`));
|
|
241
|
+
} else {
|
|
242
|
+
resolve();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Résolution sécurisée du chemin de vue
|
|
250
|
+
*/
|
|
251
|
+
resolveViewPath(view) {
|
|
252
|
+
const viewsDir = path.resolve(process.cwd(), this.app.options.viewsDir || 'views');
|
|
253
|
+
let viewPath = path.join(viewsDir, view);
|
|
254
|
+
|
|
255
|
+
// Validation contre la traversée de répertoire
|
|
256
|
+
if (!viewPath.startsWith(viewsDir)) {
|
|
257
|
+
throw new Error('Invalid view path: outside views directory');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!viewPath.endsWith('.ejs')) {
|
|
261
|
+
viewPath += '.ejs';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return viewPath;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Obtenir le chemin du layout de manière sécurisée
|
|
269
|
+
*/
|
|
270
|
+
getLayoutPath(layoutName) {
|
|
271
|
+
const layoutsDir = path.resolve(process.cwd(), this.options.layoutsDir);
|
|
272
|
+
let layoutPath = path.join(layoutsDir, layoutName);
|
|
273
|
+
|
|
274
|
+
// Validation contre la traversée de répertoire
|
|
275
|
+
if (!layoutPath.startsWith(layoutsDir)) {
|
|
276
|
+
throw new Error('Invalid layout path: outside layouts directory');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (!layoutPath.endsWith(this.options.extension)) {
|
|
280
|
+
layoutPath += this.options.extension;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return layoutPath;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Vérification asynchrone de l'existence d'un layout
|
|
288
|
+
*/
|
|
289
|
+
async layoutExists(layoutPath) {
|
|
290
|
+
return new Promise((resolve) => {
|
|
291
|
+
fs.access(layoutPath, fs.constants.R_OK, (err) => {
|
|
292
|
+
resolve(!err);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Création d'helpers de layout sécurisés
|
|
299
|
+
*/
|
|
300
|
+
createLayoutHelpers(req, res) {
|
|
301
|
+
return {
|
|
302
|
+
section: (name, content) => {
|
|
303
|
+
if (!res.locals.sections) res.locals.sections = {};
|
|
304
|
+
|
|
305
|
+
// Validation du nom de section
|
|
306
|
+
const safeName = this.sanitizeSectionName(name);
|
|
307
|
+
const safeContent = this.sanitizeSectionContent(content);
|
|
308
|
+
|
|
309
|
+
res.locals.sections[safeName] = safeContent;
|
|
310
|
+
return '';
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
css: (href) => {
|
|
314
|
+
if (!res.locals.css) res.locals.css = [];
|
|
315
|
+
|
|
316
|
+
// Validation de l'URL CSS
|
|
317
|
+
const safeHref = this.sanitizeResourceUrl(href);
|
|
318
|
+
if (safeHref && res.locals.css.length < 20) {
|
|
319
|
+
res.locals.css.push(safeHref);
|
|
320
|
+
}
|
|
321
|
+
return '';
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
js: (src) => {
|
|
325
|
+
if (!res.locals.js) res.locals.js = [];
|
|
326
|
+
|
|
327
|
+
// Validation de l'URL JavaScript
|
|
328
|
+
const safeSrc = this.sanitizeResourceUrl(src);
|
|
329
|
+
if (safeSrc && res.locals.js.length < 20) {
|
|
330
|
+
res.locals.js.push(safeSrc);
|
|
331
|
+
}
|
|
332
|
+
return '';
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
title: (title) => {
|
|
336
|
+
res.locals.title = this.sanitizeText(title, 100);
|
|
337
|
+
return '';
|
|
338
|
+
},
|
|
339
|
+
|
|
340
|
+
meta: (name, content) => {
|
|
341
|
+
if (!res.locals.meta) res.locals.meta = {};
|
|
342
|
+
|
|
343
|
+
const safeName = this.sanitizeText(name, 50);
|
|
344
|
+
const safeContent = this.sanitizeText(content, 200);
|
|
345
|
+
|
|
346
|
+
if (safeName && safeContent) {
|
|
347
|
+
res.locals.meta[safeName] = safeContent;
|
|
348
|
+
}
|
|
349
|
+
return '';
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Création asynchrone du layout par défaut
|
|
356
|
+
*/
|
|
357
|
+
async createDefaultLayoutAsync(layoutName) {
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const layoutsDir = path.join(process.cwd(), this.options.layoutsDir);
|
|
360
|
+
|
|
361
|
+
// Création du répertoire s'il n'existe pas
|
|
362
|
+
fs.mkdir(layoutsDir, { recursive: true }, (err) => {
|
|
363
|
+
if (err && err.code !== 'EEXIST') {
|
|
364
|
+
reject(err);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const layoutPath = this.getLayoutPath(layoutName);
|
|
369
|
+
|
|
370
|
+
// Vérification si le layout existe déjà
|
|
371
|
+
fs.access(layoutPath, fs.constants.F_OK, (err) => {
|
|
372
|
+
if (!err) {
|
|
373
|
+
resolve(); // Le layout existe déjà
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Création du layout
|
|
378
|
+
const defaultContent = this.generateDefaultLayoutContent();
|
|
379
|
+
fs.writeFile(layoutPath, defaultContent, 'utf8', (writeErr) => {
|
|
380
|
+
if (writeErr) {
|
|
381
|
+
reject(writeErr);
|
|
382
|
+
} else {
|
|
383
|
+
const relativePath = path.relative(process.cwd(), layoutPath);
|
|
384
|
+
this.app.logger?.log('create', 'Default layout created', `📄 ${relativePath}`);
|
|
385
|
+
resolve();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Génération du contenu de layout par défaut optimisé
|
|
395
|
+
*/
|
|
396
|
+
generateDefaultLayoutContent() {
|
|
397
|
+
return `<!DOCTYPE html>
|
|
398
|
+
<html lang="fr">
|
|
399
|
+
<head>
|
|
400
|
+
<meta charset="UTF-8">
|
|
401
|
+
<meta name="viewport" content="<%= meta.viewport %>">
|
|
402
|
+
<title><%= meta.title %></title>
|
|
403
|
+
|
|
404
|
+
<% if (meta.description) { %>
|
|
405
|
+
<meta name="description" content="<%= meta.description %>">
|
|
406
|
+
<% } %>
|
|
407
|
+
|
|
408
|
+
<% if (meta.keywords) { %>
|
|
409
|
+
<meta name="keywords" content="<%= meta.keywords %>">
|
|
410
|
+
<% } %>
|
|
411
|
+
|
|
412
|
+
<% if (meta.author) { %>
|
|
413
|
+
<meta name="author" content="<%= meta.author %>">
|
|
414
|
+
<% } %>
|
|
415
|
+
|
|
416
|
+
<!-- CSS par défaut avec CSP -->
|
|
417
|
+
<style nonce="<%= locals.nonce || '' %>">
|
|
418
|
+
* {
|
|
419
|
+
box-sizing: border-box;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
body {
|
|
423
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
424
|
+
margin: 0;
|
|
425
|
+
padding: 0;
|
|
426
|
+
line-height: 1.6;
|
|
427
|
+
color: #333;
|
|
428
|
+
background-color: #fff;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.container {
|
|
432
|
+
max-width: 1200px;
|
|
433
|
+
margin: 0 auto;
|
|
434
|
+
padding: 1rem;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
header {
|
|
438
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
439
|
+
color: white;
|
|
440
|
+
padding: 1rem 0;
|
|
441
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
main {
|
|
445
|
+
min-height: 60vh;
|
|
446
|
+
padding: 2rem 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
footer {
|
|
450
|
+
background: #333;
|
|
451
|
+
color: white;
|
|
452
|
+
text-align: center;
|
|
453
|
+
padding: 1rem 0;
|
|
454
|
+
margin-top: 2rem;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
@media (max-width: 768px) {
|
|
458
|
+
.container {
|
|
459
|
+
padding: 0.5rem;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
</style>
|
|
463
|
+
|
|
464
|
+
<!-- CSS personnalisé -->
|
|
465
|
+
<% if (locals.css && Array.isArray(locals.css)) { %>
|
|
466
|
+
<% locals.css.forEach(href => { %>
|
|
467
|
+
<link rel="stylesheet" href="<%= href %>" crossorigin="anonymous">
|
|
468
|
+
<% }); %>
|
|
469
|
+
<% } %>
|
|
470
|
+
|
|
471
|
+
<!-- Section head personnalisée -->
|
|
472
|
+
<% if (sections && sections.head) { %>
|
|
473
|
+
<%- sections.head %>
|
|
474
|
+
<% } %>
|
|
475
|
+
</head>
|
|
476
|
+
<body class="<%= locals.bodyClass || '' %>">
|
|
477
|
+
<!-- Header -->
|
|
478
|
+
<% if (sections && sections.header) { %>
|
|
479
|
+
<header>
|
|
480
|
+
<div class="container">
|
|
481
|
+
<%- sections.header %>
|
|
482
|
+
</div>
|
|
483
|
+
</header>
|
|
484
|
+
<% } else { %>
|
|
485
|
+
<header>
|
|
486
|
+
<div class="container">
|
|
487
|
+
<h1>🚀 Veko.js</h1>
|
|
488
|
+
<p>Ultra modern Node.js framework</p>
|
|
489
|
+
</div>
|
|
490
|
+
</header>
|
|
491
|
+
<% } %>
|
|
492
|
+
|
|
493
|
+
<!-- Contenu principal -->
|
|
494
|
+
<main>
|
|
495
|
+
<div class="container">
|
|
496
|
+
<%- sections.content || '' %>
|
|
497
|
+
</div>
|
|
498
|
+
</main>
|
|
499
|
+
|
|
500
|
+
<!-- Footer -->
|
|
501
|
+
<% if (sections && sections.footer) { %>
|
|
502
|
+
<footer>
|
|
503
|
+
<div class="container">
|
|
504
|
+
<%- sections.footer %>
|
|
505
|
+
</div>
|
|
506
|
+
</footer>
|
|
507
|
+
<% } else { %>
|
|
508
|
+
<footer>
|
|
509
|
+
<div class="container">
|
|
510
|
+
<p>Powered by Veko.js ⚡</p>
|
|
511
|
+
</div>
|
|
512
|
+
</footer>
|
|
513
|
+
<% } %>
|
|
514
|
+
|
|
515
|
+
<!-- JavaScript -->
|
|
516
|
+
<% if (locals.js && Array.isArray(locals.js)) { %>
|
|
517
|
+
<% locals.js.forEach(src => { %>
|
|
518
|
+
<script src="<%= src %>" crossorigin="anonymous"></script>
|
|
519
|
+
<% }); %>
|
|
520
|
+
<% } %>
|
|
521
|
+
|
|
522
|
+
<!-- Section scripts personnalisée -->
|
|
523
|
+
<% if (sections && sections.scripts) { %>
|
|
524
|
+
<%- sections.scripts %>
|
|
525
|
+
<% } %>
|
|
526
|
+
</body>
|
|
527
|
+
</html>`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Création de layout avec validation améliorée
|
|
532
|
+
*/
|
|
533
|
+
async createLayout(layoutName, content = null) {
|
|
534
|
+
try {
|
|
535
|
+
const sanitizedName = this.sanitizeLayoutName(layoutName);
|
|
536
|
+
const layoutPath = this.getLayoutPath(sanitizedName);
|
|
537
|
+
const layoutsDir = path.dirname(layoutPath);
|
|
538
|
+
|
|
539
|
+
// Création du répertoire de manière asynchrone
|
|
540
|
+
await fs.promises.mkdir(layoutsDir, { recursive: true });
|
|
541
|
+
|
|
542
|
+
const layoutContent = content || this.generateDefaultLayoutContent();
|
|
543
|
+
|
|
544
|
+
// Validation du contenu
|
|
545
|
+
if (typeof layoutContent !== 'string' || layoutContent.length > 100000) {
|
|
546
|
+
throw new Error('Invalid layout content');
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
await fs.promises.writeFile(layoutPath, layoutContent, 'utf8');
|
|
550
|
+
|
|
551
|
+
const relativePath = path.relative(process.cwd(), layoutPath);
|
|
552
|
+
this.app.logger?.log('create', 'Layout created', `📄 ${relativePath}`);
|
|
553
|
+
|
|
554
|
+
return this.app;
|
|
555
|
+
} catch (error) {
|
|
556
|
+
this.app.logger?.log('error', 'Error creating layout', error.message);
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Suppression sécurisée de layout
|
|
563
|
+
*/
|
|
564
|
+
async deleteLayout(layoutName) {
|
|
565
|
+
try {
|
|
566
|
+
const sanitizedName = this.sanitizeLayoutName(layoutName);
|
|
567
|
+
const layoutPath = this.getLayoutPath(sanitizedName);
|
|
568
|
+
|
|
569
|
+
await fs.promises.access(layoutPath, fs.constants.F_OK);
|
|
570
|
+
await fs.promises.unlink(layoutPath);
|
|
571
|
+
|
|
572
|
+
// Nettoyage du cache
|
|
573
|
+
this.layoutCache.delete(sanitizedName);
|
|
574
|
+
|
|
575
|
+
const relativePath = path.relative(process.cwd(), layoutPath);
|
|
576
|
+
this.app.logger?.log('delete', 'Layout deleted', `📄 ${relativePath}`);
|
|
577
|
+
|
|
578
|
+
return this.app;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error.code === 'ENOENT') {
|
|
581
|
+
this.app.logger?.log('warning', 'Layout not found', `📄 ${layoutName}`);
|
|
582
|
+
} else {
|
|
583
|
+
this.app.logger?.log('error', 'Error deleting layout', error.message);
|
|
584
|
+
throw error;
|
|
585
|
+
}
|
|
586
|
+
return this.app;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Liste des layouts avec gestion d'erreur
|
|
592
|
+
*/
|
|
593
|
+
async listLayouts() {
|
|
594
|
+
try {
|
|
595
|
+
const layoutsDir = path.join(process.cwd(), this.options.layoutsDir);
|
|
596
|
+
|
|
597
|
+
await fs.promises.access(layoutsDir, fs.constants.R_OK);
|
|
598
|
+
const files = await fs.promises.readdir(layoutsDir);
|
|
599
|
+
|
|
600
|
+
return files
|
|
601
|
+
.filter(file => this.securityConfig.allowedExtensions.includes(path.extname(file)))
|
|
602
|
+
.map(file => path.basename(file, this.options.extension))
|
|
603
|
+
.filter(name => name.length > 0);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (error.code === 'ENOENT') {
|
|
606
|
+
return [];
|
|
607
|
+
}
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Rechargement sécurisé des layouts
|
|
614
|
+
*/
|
|
615
|
+
reloadLayouts() {
|
|
616
|
+
this.layoutCache.clear();
|
|
617
|
+
this.layoutSections.clear();
|
|
618
|
+
this.app.logger?.log('reload', 'Layout cache cleared', '🎨 All layouts refreshed');
|
|
619
|
+
return this.app;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// === Méthodes utilitaires de sécurité ===
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Nettoyage du nom de section
|
|
626
|
+
*/
|
|
627
|
+
sanitizeSectionName(name) {
|
|
628
|
+
if (typeof name !== 'string') return 'default';
|
|
629
|
+
return name.replace(/[^a-zA-Z0-9\-_]/g, '').substring(0, 50) || 'default';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Nettoyage du contenu de section
|
|
634
|
+
*/
|
|
635
|
+
sanitizeSectionContent(content) {
|
|
636
|
+
if (typeof content !== 'string') return '';
|
|
637
|
+
return content.substring(0, this.securityConfig.maxSectionSize);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Nettoyage des URLs de ressources
|
|
642
|
+
*/
|
|
643
|
+
sanitizeResourceUrl(url) {
|
|
644
|
+
if (typeof url !== 'string') return null;
|
|
645
|
+
|
|
646
|
+
// Validation basique d'URL
|
|
647
|
+
if (url.length > 200 || url.includes('<') || url.includes('>')) {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Autoriser uniquement les URLs relatives et HTTPS
|
|
652
|
+
if (url.startsWith('/') || url.startsWith('https://') || url.startsWith('./')) {
|
|
653
|
+
return url;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Nettoyage de texte générique
|
|
661
|
+
*/
|
|
662
|
+
sanitizeText(text, maxLength = 100) {
|
|
663
|
+
if (typeof text !== 'string') return '';
|
|
664
|
+
return text.replace(/[<>]/g, '').substring(0, maxLength);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Échappement HTML
|
|
669
|
+
*/
|
|
670
|
+
escapeHtml(text) {
|
|
671
|
+
if (typeof text !== 'string') return '';
|
|
672
|
+
|
|
673
|
+
const escapeMap = {
|
|
674
|
+
'&': '&',
|
|
675
|
+
'<': '<',
|
|
676
|
+
'>': '>',
|
|
677
|
+
'"': '"',
|
|
678
|
+
"'": ''',
|
|
679
|
+
'/': '/'
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
return text.replace(/[&<>"'\/]/g, (char) => escapeMap[char]);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Nettoyage des paramètres de requête
|
|
687
|
+
*/
|
|
688
|
+
sanitizeQueryParams(query) {
|
|
689
|
+
if (!query || typeof query !== 'object') return {};
|
|
690
|
+
|
|
691
|
+
const sanitized = {};
|
|
692
|
+
for (const [key, value] of Object.entries(query)) {
|
|
693
|
+
if (typeof key === 'string' && key.length <= 50) {
|
|
694
|
+
const cleanKey = key.replace(/[^a-zA-Z0-9\-_]/g, '');
|
|
695
|
+
if (typeof value === 'string' && value.length <= 200) {
|
|
696
|
+
sanitized[cleanKey] = value;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
return sanitized;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Nettoyage des paramètres de route
|
|
706
|
+
*/
|
|
707
|
+
sanitizeParams(params) {
|
|
708
|
+
return this.sanitizeQueryParams(params);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* Fusion sécurisée des options
|
|
713
|
+
*/
|
|
714
|
+
mergeOptionsSecurely(baseData, options) {
|
|
715
|
+
const allowedFields = ['title', 'description', 'keywords', 'bodyClass', 'css', 'js'];
|
|
716
|
+
const merged = { ...baseData };
|
|
717
|
+
|
|
718
|
+
allowedFields.forEach(field => {
|
|
719
|
+
if (options[field] !== undefined) {
|
|
720
|
+
if (field === 'css' || field === 'js') {
|
|
721
|
+
if (Array.isArray(options[field])) {
|
|
722
|
+
merged.layout = merged.layout || {};
|
|
723
|
+
merged.layout[field] = options[field].slice(0, 10); // Limiter à 10 ressources
|
|
724
|
+
}
|
|
725
|
+
} else if (typeof options[field] === 'string') {
|
|
726
|
+
merged.meta = merged.meta || {};
|
|
727
|
+
merged.meta[field] = this.sanitizeText(options[field], 200);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
return merged;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Nettoyage des options de layout
|
|
737
|
+
*/
|
|
738
|
+
sanitizeLayoutOptions(layoutOptions) {
|
|
739
|
+
if (!layoutOptions || typeof layoutOptions !== 'object') {
|
|
740
|
+
return { css: [], js: [], bodyClass: '' };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
css: Array.isArray(layoutOptions.css) ? layoutOptions.css.slice(0, 10) : [],
|
|
745
|
+
js: Array.isArray(layoutOptions.js) ? layoutOptions.js.slice(0, 10) : [],
|
|
746
|
+
bodyClass: typeof layoutOptions.bodyClass === 'string'
|
|
747
|
+
? this.sanitizeText(layoutOptions.bodyClass, 100)
|
|
748
|
+
: ''
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Nettoyage du nom de vue
|
|
754
|
+
*/
|
|
755
|
+
sanitizeViewName(view) {
|
|
756
|
+
if (typeof view !== 'string') return 'index';
|
|
757
|
+
return view.replace(/[^a-zA-Z0-9\-_\/]/g, '').substring(0, 100) || 'index';
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Gestion centralisée des erreurs de rendu
|
|
762
|
+
*/
|
|
763
|
+
handleRenderError(error, res, view, options, originalRender, callback) {
|
|
764
|
+
this.app.logger?.log('error', 'Layout render error', {
|
|
765
|
+
error: error.message,
|
|
766
|
+
view,
|
|
767
|
+
stack: error.stack
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Fallback vers le rendu original
|
|
771
|
+
try {
|
|
772
|
+
originalRender.call(res, view, options, callback);
|
|
773
|
+
} catch (fallbackError) {
|
|
774
|
+
// Dernière tentative avec une réponse d'erreur
|
|
775
|
+
if (res.headersSent) return;
|
|
776
|
+
|
|
777
|
+
res.status(500);
|
|
778
|
+
if (typeof callback === 'function') {
|
|
779
|
+
callback(fallbackError);
|
|
780
|
+
} else {
|
|
781
|
+
res.send('Internal Server Error');
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Initialisation du nettoyage automatique du cache
|
|
788
|
+
*/
|
|
789
|
+
initCacheCleanup() {
|
|
790
|
+
// Nettoyage du cache toutes les 30 minutes
|
|
791
|
+
this.cleanupInterval = setInterval(() => {
|
|
792
|
+
this.cleanupCache();
|
|
793
|
+
}, 30 * 60 * 1000);
|
|
794
|
+
|
|
795
|
+
// Nettoyage au démarrage
|
|
796
|
+
process.nextTick(() => this.cleanupCache());
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Nettoyage automatique du cache
|
|
801
|
+
*/
|
|
802
|
+
cleanupCache() {
|
|
803
|
+
try {
|
|
804
|
+
// Nettoyage du cache de layouts si trop grand
|
|
805
|
+
if (this.layoutCache.size > this.securityConfig.maxCacheSize) {
|
|
806
|
+
this.layoutCache.clear();
|
|
807
|
+
this.app.logger?.log('maintenance', 'Layout cache cleared', 'Size limit exceeded');
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Nettoyage du cache de sections
|
|
811
|
+
if (this.layoutSections.size > this.securityConfig.maxCacheSize) {
|
|
812
|
+
this.layoutSections.clear();
|
|
813
|
+
this.app.logger?.log('maintenance', 'Sections cache cleared', 'Size limit exceeded');
|
|
814
|
+
}
|
|
815
|
+
} catch (error) {
|
|
816
|
+
this.app.logger?.log('error', 'Cache cleanup error', error.message);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Nettoyage lors de la destruction
|
|
822
|
+
*/
|
|
823
|
+
destroy() {
|
|
824
|
+
if (this.cleanupInterval) {
|
|
825
|
+
clearInterval(this.cleanupInterval);
|
|
826
|
+
this.cleanupInterval = null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
this.layoutCache.clear();
|
|
830
|
+
this.layoutSections.clear();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
module.exports = LayoutManager;
|