@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 +106 -13
- package/config/locales/en.js +18 -0
- package/config/locales/es.js +18 -0
- package/examples/controllers/api/AuthController.js +56 -0
- package/examples/services/Auth.js +111 -0
- package/libs/Upload.js +349 -0
- package/libs/i18n.js +9 -7
- package/package.json +2 -1
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/
|
|
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/
|
|
138
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
10
|
-
locales:
|
|
15
|
+
const {
|
|
16
|
+
locales: projectLocales
|
|
11
17
|
} = config || {};
|
|
12
18
|
|
|
13
|
-
|
|
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.
|
|
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",
|