@vulkano/core 1.22.0 → 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
@@ -116,7 +116,7 @@ GET /users/edit/1
116
116
 
117
117
  A controller method key is `<path tail>` on its own, or `'<verb> <path tail>'` when the verb isn't `GET`. The auto-router only reassigns the HTTP method when the key has a space-separated verb prefix — otherwise it defaults to **GET**.
118
118
 
119
- - A **custom action name with no verb prefix** (no space in the key) is still `GET`, e.g. `me(req, res)` on `AuthController` → `GET /auth/me`. Don't write `'get me'`; it's redundant.
119
+ - A **custom action name with no verb prefix** (no space in the key) is still `GET`, e.g. `me(req, res)` on `AuthController` → `GET /auth/current`. Don't write `'get current'`; it's redundant.
120
120
  - A **custom action that isn't `GET`** needs the verb spelled out, e.g. `'post login'` → `POST /auth/login`.
121
121
  - The path tail can carry arbitrary nested segments and multiple params:
122
122
 
@@ -134,8 +134,8 @@ module.exports = {
134
134
  // controllers/api/AuthController.js
135
135
  module.exports = {
136
136
 
137
- // GET /api/auth/me — no verb prefix needed, GET is the default
138
- me(req, res) { },
137
+ // GET /api/auth/current — no verb prefix needed, GET is the default
138
+ current(req, res) { },
139
139
 
140
140
  // POST /api/auth/login
141
141
  'post login': (req, res) => { },
@@ -304,6 +304,10 @@ module.exports = {
304
304
 
305
305
  NOTE: To find examples with the best practices for available methods ahd hooks, look in `examples/models` and read the file `Example.js`, and Scaffold Model API `ExampleWithScaffold.js`.
306
306
 
307
+ ### Vulkano models — don't hand-roll `createdAt` or `updatedAt`
308
+
309
+ `@vulkano/core`'s `database/mongodb.js` auto-injects `createdAt: Date` and `updatedAt: Date` attributes into every model schema if the model doesn't already define them (`if (!attributes.createdAt) { ... }`, same for `updatedAt`). Never add a manual timestamp field (`at`, `date`, `timestamp`, `createdAt`, `updatedAt`, etc.) to a model's `attributes` — they're already automatic in Vulkano, so a hand-rolled one is redundant, and if named anything other than `createdAt`/`updatedAt` it also fights the framework's own sort/index defaults (`database/scaffold.js` defaults `sort: 'createdAt|DESC'`). Use `createdAt` and `updatedAt` directly in indexes, sort strings, and business logic.
310
+
307
311
  ---
308
312
 
309
313
  ## Key conventions
@@ -330,6 +334,7 @@ All files in `app/services/` are auto-loaded as globals. The framework also expo
330
334
  | `Crontab` | Schedule recurring jobs with cron expressions |
331
335
  | `ApiClient` | HTTP client for calling external APIs (native fetch + undici) |
332
336
  | `Download` | File download helper |
337
+ | `Upload` | Validate, save and return the local path of an uploaded file |
333
338
  | `i18n` | Internationalization via i18next |
334
339
  | `mongoose` | Mongoose instance |
335
340
 
@@ -337,20 +342,104 @@ All files in `app/services/` are auto-loaded as globals. The framework also expo
337
342
 
338
343
  ## File Uploads
339
344
 
340
- 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`:
341
353
 
342
354
  ```js
343
- 'post upload': function (req, res) {
344
- const files = (req.files || []).map((f) => ({
345
- fieldname: f.fieldname,
346
- originalname: f.originalname,
347
- mimetype: f.mimetype,
348
- size: f.size
349
- }));
350
- res.vsr(Promise.resolve({ uploaded: files.length, files }));
351
- }
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
+ };
352
375
  ```
353
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:
394
+
395
+ ```js
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
399
+ ```
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
+
354
443
  ---
355
444
 
356
445
  ## JWT Authentication
@@ -463,6 +552,10 @@ module.exports = {
463
552
  Default language is `en`, with `en` as the fallback if a key or locale is missing. To switch the
464
553
  active language at runtime, call `i18n.changeLanguage('es')`.
465
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
+
466
559
  ---
467
560
 
468
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
+ };
@@ -0,0 +1,56 @@
1
+ /* global Auth, Jwt */
2
+
3
+ /**
4
+ * AuthController.js
5
+ */
6
+
7
+ module.exports = {
8
+
9
+ 'get current': (req, res) => {
10
+
11
+ const {
12
+ auth
13
+ } = req || {};
14
+
15
+ res.vsr(Auth.getCurrent(auth));
16
+
17
+ },
18
+
19
+ 'post login': (req, res) => {
20
+
21
+ Auth.login(req.body)
22
+ .then(({ user, token }) => {
23
+
24
+ const {
25
+ cookieName
26
+ } = Jwt.getConfig();
27
+
28
+ res.cookie(cookieName || 'token', token, {
29
+ httpOnly: true,
30
+ // secure: app.PRODUCTION,
31
+ // sameSite: 'lax',
32
+ maxAge: Auth.SESSION_MS,
33
+ });
34
+
35
+ res.vsr(Promise.resolve(user));
36
+
37
+ })
38
+ .catch((err) => {
39
+ res.vsr(Promise.reject(err));
40
+ });
41
+
42
+ },
43
+
44
+ 'post logout': (req, res) => {
45
+
46
+ const {
47
+ cookieName
48
+ } = Jwt.getConfig();
49
+
50
+ res.clearCookie(cookieName || 'token');
51
+
52
+ res.vsr(Promise.resolve({ success: true }));
53
+
54
+ }
55
+
56
+ };
@@ -0,0 +1,111 @@
1
+ /* global User, Auth, VSError */
2
+
3
+ const bcrypt = require('bcryptjs');
4
+
5
+ module.exports = {
6
+
7
+ SESSION_MS: 1000 * 60 * 60 * 24 * 365 * 10,
8
+
9
+ /**
10
+ * Verify a plain-text password against a stored hash.
11
+ * @param {String} plain
12
+ * @param {String} hash
13
+ * @returns {Boolean}
14
+ */
15
+ verifyPassword(plain, hash) {
16
+ return bcrypt.compareSync(`${process.env.SALT_KEY || ''}-${plain}`, hash);
17
+ },
18
+
19
+ /**
20
+ * Look up a user by email and verify their password. The only place in
21
+ * the model that explicitly loads the password hash.
22
+ * @param {String} email
23
+ * @param {String} password
24
+ * @returns {Promise<Object>} the authenticated User document
25
+ */
26
+ login({ email, password }) {
27
+
28
+ const normalizedEmail = String(email || '')
29
+ .toLowerCase()
30
+ .trim();
31
+
32
+ return User.findOne({ email: normalizedEmail, active: true })
33
+ .select('+password')
34
+ .then((user) => {
35
+
36
+ if (!user || !Auth.verifyPassword(password || '', user.password)) {
37
+ return VSError.reject('Invalid credentials', 401);
38
+ }
39
+
40
+ return Auth.setToken(user);
41
+ });
42
+
43
+ },
44
+
45
+ /**
46
+ * Generate token
47
+ * @param {Object} props
48
+ * @returns {Object}
49
+ */
50
+ setToken(u) {
51
+
52
+ const {
53
+ _id
54
+ } = u || {};
55
+
56
+ if (!_id) {
57
+ return VSError.reject('The user and/or password are incorrect', 400);
58
+ }
59
+
60
+ return Promise.resolve({
61
+
62
+ user: {
63
+ _id: u._id || '',
64
+ name: u.name || ''
65
+ },
66
+
67
+ token: Jwt.encode({
68
+ _id: u._id || '',
69
+ name: u.name || '',
70
+ email: u.email,
71
+ role: u.role || '',
72
+ expiration: Auth.SESSION_MS + Date.now()
73
+ })
74
+
75
+ });
76
+ },
77
+
78
+ /**
79
+ * Get current user logged
80
+ *
81
+ * @param {Object} auth
82
+ * @returns Promise
83
+ */
84
+ getCurrent(auth) {
85
+
86
+ const {
87
+ _id
88
+ } = auth || {};
89
+
90
+ if (!_id) {
91
+ return VSError.reject('Invalid token', 401);
92
+ }
93
+
94
+ return User
95
+ .getUser(_id)
96
+ .then( (u) => {
97
+
98
+ const {
99
+ active
100
+ } = u || {};
101
+
102
+ if (String(active || '') !== 'true' || !_id) {
103
+ return VSError.reject('Invalid ID. User not found', 401);
104
+ }
105
+
106
+ return u;
107
+
108
+ });
109
+
110
+ }
111
+ };
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.0",
3
+ "version": "1.23.0",
4
4
  "description": "A MVC framework using Express 4",
5
5
  "license": "MIT",
6
6
  "author": "Vulkano Team",
@@ -44,6 +44,7 @@
44
44
  "dependencies": {
45
45
  "@socket.io/mongo-adapter": "^0.4.0",
46
46
  "@socket.io/redis-adapter": "^8.3.0",
47
+ "bcryptjs": "^3.0.3",
47
48
  "compression": "^1.8.1",
48
49
  "connect-timeout": "^1.9.1",
49
50
  "cookie-parser": "^1.4.7",