@vulkano/core 1.22.1 → 1.23.1

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,106 @@ 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
+ // For custom mimetypes or mime-to-extension mapping
363
+ // mimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
364
+ maxSize: 10 * 1024 * 1024,
365
+ lang: req.query.lang // 'en' (default) or 'es' — controls the error language
366
+ };
367
+
368
+ res.vsr(
369
+ Upload
370
+ .file(req.files || [], props)
371
+ .then((file) => Cloud.upload(file.path).then((url) => ({ ...file, url })))
372
+ );
373
+
374
+ }
375
+
376
+ };
377
+ ```
378
+
379
+ `Upload.file()` resolves with `{ name, path }` — `name` is the destination filename, `path` the
380
+ absolute local path. SVGs get their content sanitized regardless of the rename strategy —
381
+ `<script>` tags, inline event handler attributes (`onload`, `onclick`, ...) and `javascript:` URIs
382
+ are stripped before the file is saved, since an SVG is just XML that can otherwise carry
383
+ executable code.
384
+
385
+ **`opts`:**
386
+
387
+ | Option | Description |
388
+ |-----------|---------------------------------------------------------------------------|
389
+ | `allowed` | Array of allowed extensions (e.g. `['jpg', 'png']`). Skipped if omitted |
390
+ | `maxSize` | Max size in bytes. Defaults to 10MB |
391
+ | `name` | Restrict to a specific form fieldname. Defaults to the first file sent |
392
+ | `lang` | Language for validation error messages. Defaults to `en` |
393
+ | `rename` | Naming strategy for the saved file — see below. Defaults to none |
394
+
395
+ **`rename`** controls the destination filename:
345
396
 
346
397
  ```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
- }
398
+ Upload.file(req.files, { rename: true }); // uuid, e.g. "3fa2...c9.png"
399
+ Upload.file(req.files, {}); // sanitized original name
400
+ Upload.file(req.files, { rename: (file) => `user-${req.auth._id}` }); // custom base name
356
401
  ```
357
402
 
403
+ - `rename: true` → a random uuid (collision-free, extension kept)
404
+ - omitted / `false` → the sanitized original filename, unchanged
405
+ - a function `(file) => string` → its return value becomes the base name (still sanitized —
406
+ never trusted as-is, since it could carry `../`)
407
+
408
+ In every case except the uuid one, if the resulting filename already exists in `PUBLIC_PATH/files`
409
+ a short random suffix is appended so the new upload doesn't silently overwrite it — a plain
410
+ upload with no collision keeps a clean name.
411
+
412
+ Any of the 5 `opts` keys above (`allowed`, `maxSize`, `name`, `lang`, `rename`) can also be set once
413
+ as project-wide defaults in `app/config/upload.js` — it's a plain JS module merged under the
414
+ `opts` passed on each call, so per-call values win:
415
+
416
+ ```js
417
+ // app/config/upload.js
418
+ module.exports = {
419
+ allowed: ['jpg', 'jpeg', 'png', 'webp'],
420
+ maxSize: 10 * 1024 * 1024
421
+ // name, lang and rename are usually left per-call since they tend to
422
+ // depend on the request, but they're valid here too
423
+ };
424
+ ```
425
+
426
+ Validation errors are `VSError`s translated through the `i18n` keys `upload.notUploaded`,
427
+ `upload.noPermission`, `upload.invalidMimeType`, `upload.extensionNotAllowed` and
428
+ `upload.maxSizeExceeded` — see [i18n](#i18n) below to override their text per locale.
429
+
430
+ ### Multiple files — `Upload.files()`
431
+
432
+ Same options as `Upload.file()`, but validates and saves every file sent (optionally restricted to
433
+ one fieldname via `opts.name`) and resolves with an array:
434
+
435
+ ```js
436
+ res.vsr(
437
+ Upload.files(req.files || [], { allowed: ['jpg', 'png'], name: 'gallery' })
438
+ );
439
+ // → [{ name, path }, { name, path }, ...]
440
+ ```
441
+
442
+ If any file fails validation, the whole call rejects (same as `Promise.all`) — no files are
443
+ partially saved.
444
+
358
445
  ---
359
446
 
360
447
  ## JWT Authentication
@@ -467,6 +554,10 @@ module.exports = {
467
554
  Default language is `en`, with `en` as the fallback if a key or locale is missing. To switch the
468
555
  active language at runtime, call `i18n.changeLanguage('es')`.
469
556
 
557
+ The core also ships its own default `en`/`es` locale files (currently the `upload.*` keys used by
558
+ the `Upload` lib). They're deep-merged under your project's `app/config/locales/`, so any key you
559
+ declare there wins over the core default — you only need to override the keys you want to change.
560
+
470
561
  ---
471
562
 
472
563
  ## 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,366 @@
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', 'image/heic', 'image/heif'];
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
+ 'image/heic': 'heic',
40
+ 'image/heif': 'heif',
41
+ 'application/pdf': 'pdf',
42
+ 'application/x-pdf': 'pdf',
43
+ 'application/msword': 'doc',
44
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
45
+ 'application/vnd.ms-excel': 'xls',
46
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
47
+ 'application/vnd.ms-powerpoint': 'ppt',
48
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
49
+ 'text/csv': 'csv',
50
+ 'application/vnd.ms-excel.sheet.binary.macroenabled.12': 'xlsb',
51
+ 'application/zip': 'zip',
52
+ 'application/x-zip-compressed': 'zip',
53
+ 'application/x-rar': 'rar',
54
+ 'application/gzip': 'gz',
55
+ 'text/plain': 'txt',
56
+ 'video/quicktime': 'mov',
57
+ 'video/mov': 'mov',
58
+ 'video/x-mov': 'mov',
59
+ 'video/ogg': 'ogv',
60
+ 'video/webm': 'webm',
61
+ 'video/mp4': 'mp4',
62
+ 'video/x-mp4': 'mp4',
63
+ 'video/3gp': '3gp',
64
+ 'video/x-3gp': '3gp',
65
+ 'video/m4v': 'm4v',
66
+ 'video/x-m4v': 'm4v',
67
+ 'video/avi': 'avi',
68
+ 'video/x-avi': 'avi',
69
+ 'video/mpg': 'mpg',
70
+ 'video/x-mpg': 'mpg'
71
+ };
72
+
73
+ const DEFAULT_MAX_SIZE = 10 * 1024 * 1024;
74
+
75
+ module.exports = {
76
+
77
+ /**
78
+ * Validate a single multer file object and move it into its final public
79
+ * location. Internal helper shared by Upload.file() and Upload.files().
80
+ *
81
+ * @param {Object} file
82
+ * @param {Object} props - { allowed, maxSize, lang, rename }
83
+ * @returns {Promise<{name: string, path: string}>}
84
+ */
85
+ _saveFile(file, props) {
86
+
87
+ const {
88
+ allowed,
89
+ mimeTypes,
90
+ extensionMap,
91
+ maxSize,
92
+ lang
93
+ } = props;
94
+
95
+ const t = (key, vars) => i18n.t(key, { lng: lang || 'en', ...vars });
96
+
97
+ return Promise
98
+ .resolve()
99
+ .then(() => {
100
+
101
+ const publicDir = path.join(PUBLIC_PATH, 'files');
102
+ if (!Upload.isWritable(publicDir)) {
103
+ throw new VSError(t('upload.noPermission'), 500);
104
+ }
105
+
106
+ if (!Upload.isValidMimeType(file, mimeTypes)) {
107
+ throw new VSError(t('upload.invalidMimeType', { mimetype: file.mimetype }), 400);
108
+ }
109
+
110
+ const ext = Upload.getExtension(file, extensionMap);
111
+
112
+ if (allowed && Array.isArray(allowed) && !allowed.includes(ext)) {
113
+ throw new VSError(t('upload.extensionNotAllowed', { ext }), 400);
114
+ }
115
+
116
+ const limit = maxSize || DEFAULT_MAX_SIZE;
117
+ if (file.size > limit) {
118
+ throw new VSError(t('upload.maxSizeExceeded', { size: Math.floor(limit / (1024 * 1024)) }), 400);
119
+ }
120
+
121
+ return { ext, publicDir };
122
+
123
+ })
124
+ .then(({ ext, publicDir }) => {
125
+
126
+ const safeName = Upload.buildSafeName(file, ext, props, publicDir);
127
+
128
+ file.originalname = safeName;
129
+
130
+ const filePath = path.normalize(file.path);
131
+ const filePublic = path.join(publicDir, safeName);
132
+
133
+ const save = ext === 'svg'
134
+ ? readFile(filePath, 'utf8')
135
+ .then((content) => writeFile(filePublic, sanitizeSvg(content)))
136
+ .then(() => unlink(filePath))
137
+ : rename(filePath, filePublic);
138
+
139
+ return save.then(() => ({
140
+ name: file.originalname,
141
+ path: filePublic
142
+ }));
143
+
144
+ });
145
+
146
+ },
147
+
148
+ /**
149
+ * Validate a single uploaded file and move it from multer's temp path to
150
+ * its final public location. Never uploads to any cloud provider -
151
+ * callers needing that should chain a separate service off the returned
152
+ * path.
153
+ *
154
+ * @param {Array} files - req.files array from multer
155
+ * @param {Object} opts - { allowed, maxSize, name, lang, rename }
156
+ * @returns {Promise<{name: string, path: string}>}
157
+ */
158
+ file(files, opts) {
159
+
160
+ const uploadConfig = app.config && app.config.upload;
161
+ const props = { ...uploadConfig, ...opts };
162
+
163
+ const file = Upload.isUploaded(files, props.name);
164
+ const t = (key, vars) => i18n.t(key, { lng: props.lang || 'en', ...vars });
165
+
166
+ if (!file) {
167
+ return Promise.reject(new VSError(t('upload.notUploaded'), 400));
168
+ }
169
+
170
+ return Upload._saveFile(file, props);
171
+
172
+ },
173
+
174
+ /**
175
+ * Same as Upload.file() but validates and saves every matching file.
176
+ * Rejects on the first invalid file (same as Promise.all).
177
+ *
178
+ * @param {Array} files - req.files array from multer
179
+ * @param {Object} opts - { allowed, maxSize, name, lang, rename }
180
+ * @returns {Promise<Array<{name: string, path: string}>>}
181
+ */
182
+ files(files, opts) {
183
+
184
+ const uploadConfig = app.config && app.config.upload;
185
+ const props = { ...uploadConfig, ...opts };
186
+
187
+ const matched = Upload.isUploadedMany(files, props.name);
188
+ const t = (key, vars) => i18n.t(key, { lng: props.lang || 'en', ...vars });
189
+
190
+ if (!matched.length) {
191
+ return Promise.reject(new VSError(t('upload.notUploaded'), 400));
192
+ }
193
+
194
+ return Promise.all(matched.map((file) => Upload._saveFile(file, props)));
195
+
196
+ },
197
+
198
+ /**
199
+ * Check if a file was uploaded for the expected field.
200
+ *
201
+ * @param {Array} file - req.files array from multer
202
+ * @param {string} fieldname
203
+ * @returns {Object|false}
204
+ */
205
+ isUploaded(file, fieldname) {
206
+
207
+ const tmp = (file && file[0]) || null;
208
+ if (!tmp) {
209
+ return false;
210
+ }
211
+ return (!fieldname || tmp.fieldname === fieldname) ? tmp : false;
212
+
213
+ },
214
+
215
+ /**
216
+ * Same as isUploaded() but returns every matching file instead of just
217
+ * the first one.
218
+ *
219
+ * @param {Array} files - req.files array from multer
220
+ * @param {string} fieldname
221
+ * @returns {Array}
222
+ */
223
+ isUploadedMany(files, fieldname) {
224
+
225
+ if (!Array.isArray(files)) {
226
+ return [];
227
+ }
228
+ return fieldname ? files.filter((f) => f.fieldname === fieldname) : files;
229
+
230
+ },
231
+
232
+ /**
233
+ * Check if a directory is writable.
234
+ *
235
+ * @param {string} dir
236
+ * @returns {boolean}
237
+ */
238
+ isWritable(dir) {
239
+
240
+ try {
241
+ fs.accessSync(dir, fs.constants.W_OK);
242
+ return true;
243
+ } catch (err) {
244
+ return false;
245
+ }
246
+
247
+ },
248
+
249
+ /**
250
+ * Get the lowercased file extension, falling back to mimetype lookup.
251
+ *
252
+ * `extensionMap` (from `props.extensionMap`) lets a caller extend the
253
+ * built-in mimetype→extension fallback with types the core doesn't know
254
+ * about yet - needed for a nameless upload (e.g. a blob) whose extension
255
+ * can only be derived from its mimetype, such as
256
+ * `extensionMap: { 'image/heic': 'heic' }`.
257
+ *
258
+ * @param {Object} file
259
+ * @param {Object} [extensionMap] - extra mimetype→extension entries, checked before the built-in map
260
+ * @returns {string}
261
+ */
262
+ getExtension(file, extensionMap) {
263
+
264
+ let ext = (file.originalname || '').split('.').pop();
265
+
266
+ if (!ext || ext === 'blob' || ext === file.originalname) {
267
+ ext = (extensionMap && extensionMap[file.mimetype]) || MIME_EXTENSION_MAP[file.mimetype] || '';
268
+ }
269
+
270
+ return ext.toLowerCase();
271
+
272
+ },
273
+
274
+ /**
275
+ * Check if the file's mimetype is in the allowed list.
276
+ *
277
+ * `mimeTypes` (from `props.mimeTypes`) lets a caller extend the built-in
278
+ * whitelist with mimetypes the core doesn't know about yet, without
279
+ * having to fork this file - e.g. `mimeTypes: ['image/heic']`.
280
+ *
281
+ * @param {Object} file
282
+ * @param {Array} [mimeTypes] - extra mimetypes to accept, in addition to VALID_MIME_TYPES
283
+ * @returns {boolean}
284
+ */
285
+ isValidMimeType(file, mimeTypes) {
286
+
287
+ const extra = Array.isArray(mimeTypes) ? mimeTypes : [];
288
+ return VALID_MIME_TYPES.includes(file.mimetype) || extra.includes(file.mimetype);
289
+
290
+ },
291
+
292
+ /**
293
+ * Build the destination filename.
294
+ *
295
+ * - `props.rename === true` → random uuid (extension kept)
296
+ * - `props.rename` is a function → `props.rename(file)` picks the base
297
+ * name (still sanitized here - never trust it as-is, it can carry `../`)
298
+ * - otherwise → sanitized original name, unchanged
299
+ *
300
+ * In every case except the uuid one, a short suffix is appended only if
301
+ * the resulting name already exists in `publicDir`, so a normal upload
302
+ * keeps a clean name and only collisions get disambiguated.
303
+ *
304
+ * @param {Object} file
305
+ * @param {string} ext
306
+ * @param {Object} props - { rename }
307
+ * @param {string} publicDir
308
+ * @returns {string}
309
+ */
310
+ buildSafeName(file, ext, props, publicDir) {
311
+
312
+ const { rename: renameOpt } = props || {};
313
+
314
+ if (renameOpt === true) {
315
+ return `${crypto.randomUUID()}.${ext}`;
316
+ }
317
+
318
+ const source = typeof renameOpt === 'function'
319
+ ? renameOpt(file)
320
+ : path.basename(file.originalname || 'file', path.extname(file.originalname || ''));
321
+
322
+ const base = Upload.sanitizeBaseName(source);
323
+
324
+ return Upload.ensureUniqueName(publicDir, base, ext);
325
+
326
+ },
327
+
328
+ /**
329
+ * Strip anything that isn't alphanumeric/underscore/dash - originalname
330
+ * (and any custom name from `rename`) must never be trusted directly,
331
+ * it can carry `../` path segments.
332
+ *
333
+ * @param {string} name
334
+ * @returns {string}
335
+ */
336
+ sanitizeBaseName(name) {
337
+
338
+ return String(name || '')
339
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
340
+ .slice(0, 100) || 'file';
341
+
342
+ },
343
+
344
+ /**
345
+ * Append a short random suffix only if `base.ext` already exists in dir,
346
+ * so an upload without a rename strategy doesn't silently overwrite an
347
+ * existing file.
348
+ *
349
+ * @param {string} dir
350
+ * @param {string} base
351
+ * @param {string} ext
352
+ * @returns {string}
353
+ */
354
+ ensureUniqueName(dir, base, ext) {
355
+
356
+ let candidate = `${base}.${ext}`;
357
+
358
+ while (fs.existsSync(path.join(dir, candidate))) {
359
+ candidate = `${base}_${crypto.randomBytes(3).toString('hex')}.${ext}`;
360
+ }
361
+
362
+ return candidate;
363
+
364
+ }
365
+
366
+ };
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.1",
4
4
  "description": "A MVC framework using Express 4",
5
5
  "license": "MIT",
6
6
  "author": "Vulkano Team",