@vulkano/core 1.22.1 → 1.23.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/README.md CHANGED
@@ -334,6 +334,7 @@ All files in `app/services/` are auto-loaded as globals. The framework also expo
334
334
  | `Crontab` | Schedule recurring jobs with cron expressions |
335
335
  | `ApiClient` | HTTP client for calling external APIs (native fetch + undici) |
336
336
  | `Download` | File download helper |
337
+ | `Upload` | Validate, save and return the local path of an uploaded file |
337
338
  | `i18n` | Internationalization via i18next |
338
339
  | `mongoose` | Mongoose instance |
339
340
 
@@ -341,20 +342,104 @@ All files in `app/services/` are auto-loaded as globals. The framework also expo
341
342
 
342
343
  ## File Uploads
343
344
 
344
- Vulkano uses [Multer](https://github.com/expressjs/multer) v2. Files are available on `req.files` after a `multipart/form-data` POST:
345
+ Vulkano uses [Multer](https://github.com/expressjs/multer) v2. Files are available on `req.files` after a `multipart/form-data` POST — Multer writes them straight into `PUBLIC_PATH/files` under a temporary name.
346
+
347
+ ### The `Upload` lib
348
+
349
+ `Upload.file(files, opts)` validates a single uploaded file (mimetype, extension, size, write
350
+ permission) and renames it to its final, safe filename inside `PUBLIC_PATH/files`. It only ever
351
+ touches the local disk — it never uploads anywhere. If your app needs to push the result to a
352
+ cloud provider, chain your own service off the returned `path`:
353
+
354
+ ```js
355
+ // app/controllers/UploadController.js
356
+ module.exports = {
357
+
358
+ 'post upload': (req, res) => {
359
+
360
+ const props = {
361
+ allowed: ['jpg', 'jpeg', 'png', 'webp'],
362
+ maxSize: 10 * 1024 * 1024,
363
+ lang: req.query.lang // 'en' (default) or 'es' — controls the error language
364
+ };
365
+
366
+ res.vsr(
367
+ Upload
368
+ .file(req.files || [], props)
369
+ .then((file) => Cloud.upload(file.path).then((url) => ({ ...file, url })))
370
+ );
371
+
372
+ }
373
+
374
+ };
375
+ ```
376
+
377
+ `Upload.file()` resolves with `{ name, path }` — `name` is the destination filename, `path` the
378
+ absolute local path. SVGs get their content sanitized regardless of the rename strategy —
379
+ `<script>` tags, inline event handler attributes (`onload`, `onclick`, ...) and `javascript:` URIs
380
+ are stripped before the file is saved, since an SVG is just XML that can otherwise carry
381
+ executable code.
382
+
383
+ **`opts`:**
384
+
385
+ | Option | Description |
386
+ |-----------|---------------------------------------------------------------------------|
387
+ | `allowed` | Array of allowed extensions (e.g. `['jpg', 'png']`). Skipped if omitted |
388
+ | `maxSize` | Max size in bytes. Defaults to 10MB |
389
+ | `name` | Restrict to a specific form fieldname. Defaults to the first file sent |
390
+ | `lang` | Language for validation error messages. Defaults to `en` |
391
+ | `rename` | Naming strategy for the saved file — see below. Defaults to none |
392
+
393
+ **`rename`** controls the destination filename:
345
394
 
346
395
  ```js
347
- 'post upload': function (req, res) {
348
- const files = (req.files || []).map((f) => ({
349
- fieldname: f.fieldname,
350
- originalname: f.originalname,
351
- mimetype: f.mimetype,
352
- size: f.size
353
- }));
354
- res.vsr(Promise.resolve({ uploaded: files.length, files }));
355
- }
396
+ Upload.file(req.files, { rename: true }); // uuid, e.g. "3fa2...c9.png"
397
+ Upload.file(req.files, {}); // sanitized original name
398
+ Upload.file(req.files, { rename: (file) => `user-${req.auth._id}` }); // custom base name
356
399
  ```
357
400
 
401
+ - `rename: true` → a random uuid (collision-free, extension kept)
402
+ - omitted / `false` → the sanitized original filename, unchanged
403
+ - a function `(file) => string` → its return value becomes the base name (still sanitized —
404
+ never trusted as-is, since it could carry `../`)
405
+
406
+ In every case except the uuid one, if the resulting filename already exists in `PUBLIC_PATH/files`
407
+ a short random suffix is appended so the new upload doesn't silently overwrite it — a plain
408
+ upload with no collision keeps a clean name.
409
+
410
+ Any of the 5 `opts` keys above (`allowed`, `maxSize`, `name`, `lang`, `rename`) can also be set once
411
+ as project-wide defaults in `app/config/upload.js` — it's a plain JS module merged under the
412
+ `opts` passed on each call, so per-call values win:
413
+
414
+ ```js
415
+ // app/config/upload.js
416
+ module.exports = {
417
+ allowed: ['jpg', 'jpeg', 'png', 'webp'],
418
+ maxSize: 10 * 1024 * 1024
419
+ // name, lang and rename are usually left per-call since they tend to
420
+ // depend on the request, but they're valid here too
421
+ };
422
+ ```
423
+
424
+ Validation errors are `VSError`s translated through the `i18n` keys `upload.notUploaded`,
425
+ `upload.noPermission`, `upload.invalidMimeType`, `upload.extensionNotAllowed` and
426
+ `upload.maxSizeExceeded` — see [i18n](#i18n) below to override their text per locale.
427
+
428
+ ### Multiple files — `Upload.files()`
429
+
430
+ Same options as `Upload.file()`, but validates and saves every file sent (optionally restricted to
431
+ one fieldname via `opts.name`) and resolves with an array:
432
+
433
+ ```js
434
+ res.vsr(
435
+ Upload.files(req.files || [], { allowed: ['jpg', 'png'], name: 'gallery' })
436
+ );
437
+ // → [{ name, path }, { name, path }, ...]
438
+ ```
439
+
440
+ If any file fails validation, the whole call rejects (same as `Promise.all`) — no files are
441
+ partially saved.
442
+
358
443
  ---
359
444
 
360
445
  ## JWT Authentication
@@ -467,6 +552,10 @@ module.exports = {
467
552
  Default language is `en`, with `en` as the fallback if a key or locale is missing. To switch the
468
553
  active language at runtime, call `i18n.changeLanguage('es')`.
469
554
 
555
+ The core also ships its own default `en`/`es` locale files (currently the `upload.*` keys used by
556
+ the `Upload` lib). They're deep-merged under your project's `app/config/locales/`, so any key you
557
+ declare there wins over the core default — you only need to override the keys you want to change.
558
+
470
559
  ---
471
560
 
472
561
  ## Socket.io
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Locale — English (core defaults)
3
+ *
4
+ * Overridden by matching keys in the project's app/config/locales/en.js
5
+ *
6
+ */
7
+
8
+ module.exports = {
9
+
10
+ upload: {
11
+ notUploaded: 'The file could not be uploaded',
12
+ noPermission: 'The folder doesn\'t have permission to save the file',
13
+ invalidMimeType: 'The MIME type of the selected file is not allowed: {{mimetype}}',
14
+ extensionNotAllowed: 'The extension file is not allowed: {{ext}}',
15
+ maxSizeExceeded: 'The file exceeds the maximum allowed size of {{size}}MB'
16
+ }
17
+
18
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Locale — Español (defaults del core)
3
+ *
4
+ * Sobreescrito por las mismas claves en app/config/locales/es.js del proyecto
5
+ *
6
+ */
7
+
8
+ module.exports = {
9
+
10
+ upload: {
11
+ notUploaded: 'No se pudo subir el archivo',
12
+ noPermission: 'La carpeta no tiene permisos para guardar el archivo',
13
+ invalidMimeType: 'El tipo MIME del archivo seleccionado no está permitido: {{mimetype}}',
14
+ extensionNotAllowed: 'La extensión del archivo no está permitida: {{ext}}',
15
+ maxSizeExceeded: 'El archivo excede el tamaño máximo permitido de {{size}}MB'
16
+ }
17
+
18
+ };
package/libs/Upload.js ADDED
@@ -0,0 +1,349 @@
1
+ /* global VSError, i18n */
2
+
3
+ const path = require('node:path');
4
+ const fs = require('node:fs');
5
+ const crypto = require('node:crypto');
6
+
7
+ const { rename, readFile, writeFile, unlink } = fs.promises;
8
+
9
+ // Strips executable content from an SVG (a text/XML format) before it's
10
+ // served as an "image" — <script>, inline event handlers and javascript:
11
+ // URIs would otherwise run in the browser of anyone who opens the file.
12
+ function sanitizeSvg(content) {
13
+
14
+ return content
15
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
16
+ .replace(/\son\w+\s*=\s*"[^"]*"/gi, '')
17
+ .replace(/\son\w+\s*=\s*'[^']*'/gi, '')
18
+ .replace(/((?:xlink:)?href)\s*=\s*"\s*javascript:[^"]*"/gi, '$1=""')
19
+ .replace(/((?:xlink:)?href)\s*=\s*'\s*javascript:[^']*'/gi, '$1=\'\'');
20
+
21
+ }
22
+
23
+ const IMAGE_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/png', 'image/webp', 'image/svg+xml'];
24
+ const DOCUMENT_MIME_TYPES = ['application/pdf', 'application/x-pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'text/csv', 'application/vnd.ms-excel.sheet.binary.macroenabled.12'];
25
+ const ARCHIVE_MIME_TYPES = ['application/zip', 'application/x-rar', 'application/gzip', 'text/plain', 'application/x-zip-compressed'];
26
+ const VIDEO_MIME_TYPES = ['video/quicktime', 'video/ogg', 'video/webm', 'video/mp4', 'video/x-mp4', 'video/3gp', 'video/x-3gp', 'video/mov', 'video/x-mov', 'video/m4v', 'video/x-m4v', 'video/avi', 'video/x-avi', 'video/mpg', 'video/x-mpg'];
27
+
28
+ const VALID_MIME_TYPES = [...IMAGE_MIME_TYPES, ...DOCUMENT_MIME_TYPES, ...ARCHIVE_MIME_TYPES, ...VIDEO_MIME_TYPES];
29
+
30
+ // Fallback when originalname has no usable extension (e.g. a blob)
31
+ const MIME_EXTENSION_MAP = {
32
+ 'image/jpeg': 'jpg',
33
+ 'image/jpg': 'jpg',
34
+ 'image/pjpeg': 'jpg',
35
+ 'image/gif': 'gif',
36
+ 'image/png': 'png',
37
+ 'image/webp': 'webp',
38
+ 'image/svg+xml': 'svg',
39
+ 'application/pdf': 'pdf',
40
+ 'application/x-pdf': 'pdf',
41
+ 'application/msword': 'doc',
42
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
43
+ 'application/vnd.ms-excel': 'xls',
44
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
45
+ 'application/vnd.ms-powerpoint': 'ppt',
46
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
47
+ 'text/csv': 'csv',
48
+ 'application/vnd.ms-excel.sheet.binary.macroenabled.12': 'xlsb',
49
+ 'application/zip': 'zip',
50
+ 'application/x-zip-compressed': 'zip',
51
+ 'application/x-rar': 'rar',
52
+ 'application/gzip': 'gz',
53
+ 'text/plain': 'txt',
54
+ 'video/quicktime': 'mov',
55
+ 'video/mov': 'mov',
56
+ 'video/x-mov': 'mov',
57
+ 'video/ogg': 'ogv',
58
+ 'video/webm': 'webm',
59
+ 'video/mp4': 'mp4',
60
+ 'video/x-mp4': 'mp4',
61
+ 'video/3gp': '3gp',
62
+ 'video/x-3gp': '3gp',
63
+ 'video/m4v': 'm4v',
64
+ 'video/x-m4v': 'm4v',
65
+ 'video/avi': 'avi',
66
+ 'video/x-avi': 'avi',
67
+ 'video/mpg': 'mpg',
68
+ 'video/x-mpg': 'mpg'
69
+ };
70
+
71
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
72
+
73
+ module.exports = {
74
+
75
+ /**
76
+ * Validate a single multer file object and move it into its final public
77
+ * location. Internal helper shared by Upload.file() and Upload.files().
78
+ *
79
+ * @param {Object} file
80
+ * @param {Object} props - { allowed, maxSize, lang, rename }
81
+ * @returns {Promise<{name: string, path: string}>}
82
+ */
83
+ _saveFile(file, props) {
84
+
85
+ const {
86
+ allowed,
87
+ maxSize,
88
+ lang
89
+ } = props;
90
+
91
+ const t = (key, vars) => i18n.t(key, { lng: lang || 'en', ...vars });
92
+
93
+ return Promise
94
+ .resolve()
95
+ .then(() => {
96
+
97
+ const publicDir = path.join(PUBLIC_PATH, 'files');
98
+ if (!Upload.isWritable(publicDir)) {
99
+ throw new VSError(t('upload.noPermission'), 500);
100
+ }
101
+
102
+ if (!Upload.isValidMimeType(file)) {
103
+ throw new VSError(t('upload.invalidMimeType', { mimetype: file.mimetype }), 400);
104
+ }
105
+
106
+ const ext = Upload.getExtension(file);
107
+
108
+ if (allowed && Array.isArray(allowed) && !allowed.includes(ext)) {
109
+ throw new VSError(t('upload.extensionNotAllowed', { ext }), 400);
110
+ }
111
+
112
+ const limit = maxSize || DEFAULT_MAX_SIZE;
113
+ if (file.size > limit) {
114
+ throw new VSError(t('upload.maxSizeExceeded', { size: Math.floor(limit / (1024 * 1024)) }), 400);
115
+ }
116
+
117
+ return { ext, publicDir };
118
+
119
+ })
120
+ .then(({ ext, publicDir }) => {
121
+
122
+ const safeName = Upload.buildSafeName(file, ext, props, publicDir);
123
+
124
+ file.originalname = safeName;
125
+
126
+ const filePath = path.normalize(file.path);
127
+ const filePublic = path.join(publicDir, safeName);
128
+
129
+ const save = ext === 'svg'
130
+ ? readFile(filePath, 'utf8')
131
+ .then((content) => writeFile(filePublic, sanitizeSvg(content)))
132
+ .then(() => unlink(filePath))
133
+ : rename(filePath, filePublic);
134
+
135
+ return save.then(() => ({
136
+ name: file.originalname,
137
+ path: filePublic
138
+ }));
139
+
140
+ });
141
+
142
+ },
143
+
144
+ /**
145
+ * Validate a single uploaded file and move it from multer's temp path to
146
+ * its final public location. Never uploads to any cloud provider -
147
+ * callers needing that should chain a separate service off the returned
148
+ * path.
149
+ *
150
+ * @param {Array} files - req.files array from multer
151
+ * @param {Object} opts - { allowed, maxSize, name, lang, rename }
152
+ * @returns {Promise<{name: string, path: string}>}
153
+ */
154
+ file(files, opts) {
155
+
156
+ const uploadConfig = app.config && app.config.upload;
157
+ const props = { ...uploadConfig, ...opts };
158
+
159
+ const file = Upload.isUploaded(files, props.name);
160
+ const t = (key, vars) => i18n.t(key, { lng: props.lang || 'en', ...vars });
161
+
162
+ if (!file) {
163
+ return Promise.reject(new VSError(t('upload.notUploaded'), 400));
164
+ }
165
+
166
+ return Upload._saveFile(file, props);
167
+
168
+ },
169
+
170
+ /**
171
+ * Same as Upload.file() but validates and saves every matching file.
172
+ * Rejects on the first invalid file (same as Promise.all).
173
+ *
174
+ * @param {Array} files - req.files array from multer
175
+ * @param {Object} opts - { allowed, maxSize, name, lang, rename }
176
+ * @returns {Promise<Array<{name: string, path: string}>>}
177
+ */
178
+ files(files, opts) {
179
+
180
+ const uploadConfig = app.config && app.config.upload;
181
+ const props = { ...uploadConfig, ...opts };
182
+
183
+ const matched = Upload.isUploadedMany(files, props.name);
184
+ const t = (key, vars) => i18n.t(key, { lng: props.lang || 'en', ...vars });
185
+
186
+ if (!matched.length) {
187
+ return Promise.reject(new VSError(t('upload.notUploaded'), 400));
188
+ }
189
+
190
+ return Promise.all(matched.map((file) => Upload._saveFile(file, props)));
191
+
192
+ },
193
+
194
+ /**
195
+ * Check if a file was uploaded for the expected field.
196
+ *
197
+ * @param {Array} file - req.files array from multer
198
+ * @param {string} fieldname
199
+ * @returns {Object|false}
200
+ */
201
+ isUploaded(file, fieldname) {
202
+
203
+ const tmp = (file && file[0]) || null;
204
+ if (!tmp) {
205
+ return false;
206
+ }
207
+ return (!fieldname || tmp.fieldname === fieldname) ? tmp : false;
208
+
209
+ },
210
+
211
+ /**
212
+ * Same as isUploaded() but returns every matching file instead of just
213
+ * the first one.
214
+ *
215
+ * @param {Array} files - req.files array from multer
216
+ * @param {string} fieldname
217
+ * @returns {Array}
218
+ */
219
+ isUploadedMany(files, fieldname) {
220
+
221
+ if (!Array.isArray(files)) {
222
+ return [];
223
+ }
224
+ return fieldname ? files.filter((f) => f.fieldname === fieldname) : files;
225
+
226
+ },
227
+
228
+ /**
229
+ * Check if a directory is writable.
230
+ *
231
+ * @param {string} dir
232
+ * @returns {boolean}
233
+ */
234
+ isWritable(dir) {
235
+
236
+ try {
237
+ fs.accessSync(dir, fs.constants.W_OK);
238
+ return true;
239
+ } catch (err) {
240
+ return false;
241
+ }
242
+
243
+ },
244
+
245
+ /**
246
+ * Get the lowercased file extension, falling back to mimetype lookup.
247
+ *
248
+ * @param {Object} file
249
+ * @returns {string}
250
+ */
251
+ getExtension(file) {
252
+
253
+ let ext = (file.originalname || '').split('.').pop();
254
+
255
+ if (!ext || ext === 'blob' || ext === file.originalname) {
256
+ ext = MIME_EXTENSION_MAP[file.mimetype] || '';
257
+ }
258
+
259
+ return ext.toLowerCase();
260
+
261
+ },
262
+
263
+ /**
264
+ * Check if the file's mimetype is in the allowed list.
265
+ *
266
+ * @param {Object} file
267
+ * @returns {boolean}
268
+ */
269
+ isValidMimeType(file) {
270
+
271
+ return VALID_MIME_TYPES.includes(file.mimetype);
272
+
273
+ },
274
+
275
+ /**
276
+ * Build the destination filename.
277
+ *
278
+ * - `props.rename === true` → random uuid (extension kept)
279
+ * - `props.rename` is a function → `props.rename(file)` picks the base
280
+ * name (still sanitized here - never trust it as-is, it can carry `../`)
281
+ * - otherwise → sanitized original name, unchanged
282
+ *
283
+ * In every case except the uuid one, a short suffix is appended only if
284
+ * the resulting name already exists in `publicDir`, so a normal upload
285
+ * keeps a clean name and only collisions get disambiguated.
286
+ *
287
+ * @param {Object} file
288
+ * @param {string} ext
289
+ * @param {Object} props - { rename }
290
+ * @param {string} publicDir
291
+ * @returns {string}
292
+ */
293
+ buildSafeName(file, ext, props, publicDir) {
294
+
295
+ const { rename: renameOpt } = props || {};
296
+
297
+ if (renameOpt === true) {
298
+ return `${crypto.randomUUID()}.${ext}`;
299
+ }
300
+
301
+ const source = typeof renameOpt === 'function'
302
+ ? renameOpt(file)
303
+ : path.basename(file.originalname || 'file', path.extname(file.originalname || ''));
304
+
305
+ const base = Upload.sanitizeBaseName(source);
306
+
307
+ return Upload.ensureUniqueName(publicDir, base, ext);
308
+
309
+ },
310
+
311
+ /**
312
+ * Strip anything that isn't alphanumeric/underscore/dash - originalname
313
+ * (and any custom name from `rename`) must never be trusted directly,
314
+ * it can carry `../` path segments.
315
+ *
316
+ * @param {string} name
317
+ * @returns {string}
318
+ */
319
+ sanitizeBaseName(name) {
320
+
321
+ return String(name || '')
322
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
323
+ .slice(0, 100) || 'file';
324
+
325
+ },
326
+
327
+ /**
328
+ * Append a short random suffix only if `base.ext` already exists in dir,
329
+ * so an upload without a rename strategy doesn't silently overwrite an
330
+ * existing file.
331
+ *
332
+ * @param {string} dir
333
+ * @param {string} base
334
+ * @param {string} ext
335
+ * @returns {string}
336
+ */
337
+ ensureUniqueName(dir, base, ext) {
338
+
339
+ let candidate = `${base}.${ext}`;
340
+
341
+ while (fs.existsSync(path.join(dir, candidate))) {
342
+ candidate = `${base}_${crypto.randomBytes(3).toString('hex')}.${ext}`;
343
+ }
344
+
345
+ return candidate;
346
+
347
+ }
348
+
349
+ };
package/libs/i18n.js CHANGED
@@ -1,4 +1,10 @@
1
1
  const i18next = require('i18next');
2
+ const merge = require('./Merge');
3
+
4
+ const coreLocales = {
5
+ en: require('../config/locales/en'),
6
+ es: require('../config/locales/es')
7
+ };
2
8
 
3
9
  module.exports = (() => {
4
10
 
@@ -6,15 +12,11 @@ module.exports = (() => {
6
12
  config
7
13
  } = app || {};
8
14
 
9
- let {
10
- locales: configLocales
15
+ const {
16
+ locales: projectLocales
11
17
  } = config || {};
12
18
 
13
- if (!configLocales) {
14
- configLocales = {
15
- en: {}
16
- };
17
- }
19
+ const configLocales = merge.all([coreLocales, projectLocales || {}]);
18
20
 
19
21
  const locales = Object.keys(configLocales);
20
22
  const resources = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vulkano/core",
3
- "version": "1.22.1",
3
+ "version": "1.23.0",
4
4
  "description": "A MVC framework using Express 4",
5
5
  "license": "MIT",
6
6
  "author": "Vulkano Team",