millas 0.2.15 → 0.2.16
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/package.json +14 -2
- package/src/container/Application.js +2 -0
- package/src/core/foundation.js +7 -1
- package/src/core/http.js +4 -0
- package/src/http/RequestContext.js +4 -1
- package/src/http/UploadedFile.js +412 -0
- package/src/http/middleware/UploadMiddleware.js +287 -0
- package/src/router/Router.js +80 -2
- package/src/storage/Storage.js +4 -0
- package/src/storage/drivers/S3Driver.js +471 -0
- package/src/validation/types.js +25 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "millas",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
4
4
|
"description": "A modern batteries-included backend framework for Node.js — built on Express, inspired by Laravel, Django, and FastAPI",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -54,11 +54,23 @@
|
|
|
54
54
|
"sqlite3": "5.1.6"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"express": "^4.18.0"
|
|
57
|
+
"express": "^4.18.0",
|
|
58
|
+
"multer": "^1.4.5 || ^2.0.0",
|
|
59
|
+
"@aws-sdk/client-s3": "^3.0.0",
|
|
60
|
+
"@aws-sdk/s3-request-presigner": "^3.0.0"
|
|
58
61
|
},
|
|
59
62
|
"peerDependenciesMeta": {
|
|
60
63
|
"express": {
|
|
61
64
|
"optional": false
|
|
65
|
+
},
|
|
66
|
+
"multer": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@aws-sdk/client-s3": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"@aws-sdk/s3-request-presigner": {
|
|
73
|
+
"optional": true
|
|
62
74
|
}
|
|
63
75
|
},
|
|
64
76
|
"files": [
|
|
@@ -324,6 +324,8 @@ class Application {
|
|
|
324
324
|
this._mwRegistry.register('throttle', ThrottleMiddleware);
|
|
325
325
|
this._mwRegistry.register('log', new LogMiddleware());
|
|
326
326
|
this._mwRegistry.register('auth', AuthMiddleware);
|
|
327
|
+
const UploadMiddleware = require('../http/middleware/UploadMiddleware');
|
|
328
|
+
this._mwRegistry.register('upload', new UploadMiddleware());
|
|
327
329
|
}
|
|
328
330
|
}
|
|
329
331
|
|
package/src/core/foundation.js
CHANGED
|
@@ -41,6 +41,9 @@ const { CacheServiceProvider, StorageServiceProvider } = require('../providers/C
|
|
|
41
41
|
// ── Storage ───────────────────────────────────────────────────────
|
|
42
42
|
const Storage = require('../storage/Storage');
|
|
43
43
|
const LocalDriver = require('../storage/drivers/LocalDriver');
|
|
44
|
+
const S3Driver = require('../storage/drivers/S3Driver');
|
|
45
|
+
const UploadMiddleware = require('../http/middleware/UploadMiddleware');
|
|
46
|
+
const UploadedFile = require('../http/UploadedFile');
|
|
44
47
|
// ── Serializer ────────────────────────────────────────────────────
|
|
45
48
|
const { Serializer } = require('../serializer/Serializer');
|
|
46
49
|
const {Str, FluentString} = require("../support/Str");
|
|
@@ -58,7 +61,10 @@ module.exports = {
|
|
|
58
61
|
// Cache
|
|
59
62
|
Cache, MemoryDriver, FileDriver, NullDriver, CacheServiceProvider,
|
|
60
63
|
// Storage
|
|
61
|
-
Storage, LocalDriver, StorageServiceProvider,
|
|
64
|
+
Storage, LocalDriver, S3Driver, StorageServiceProvider,
|
|
65
|
+
// Upload
|
|
66
|
+
UploadMiddleware,
|
|
67
|
+
UploadedFile,
|
|
62
68
|
// Serializer
|
|
63
69
|
Serializer,
|
|
64
70
|
// Support
|
package/src/core/http.js
CHANGED
|
@@ -4,6 +4,8 @@ const MiddlewarePipeline = require('../middleware/MiddlewarePipeline');
|
|
|
4
4
|
const CorsMiddleware = require('../middleware/CorsMiddleware');
|
|
5
5
|
const ThrottleMiddleware = require('../middleware/ThrottleMiddleware');
|
|
6
6
|
const LogMiddleware = require('../middleware/LogMiddleware');
|
|
7
|
+
const UploadMiddleware = require('../http/middleware/UploadMiddleware');
|
|
8
|
+
const UploadedFile = require('../http/UploadedFile');
|
|
7
9
|
const HttpError = require('../errors/HttpError');
|
|
8
10
|
const { shape, isShape } = require('../http/Shape');
|
|
9
11
|
|
|
@@ -14,6 +16,8 @@ module.exports = {
|
|
|
14
16
|
CorsMiddleware,
|
|
15
17
|
ThrottleMiddleware,
|
|
16
18
|
LogMiddleware,
|
|
19
|
+
UploadMiddleware,
|
|
20
|
+
UploadedFile,
|
|
17
21
|
HttpError,
|
|
18
22
|
// Shape factory — define route input/output contracts
|
|
19
23
|
shape,
|
|
@@ -98,9 +98,12 @@ class RequestContext {
|
|
|
98
98
|
this.body = this._buildBody(rawBody, millaReq);
|
|
99
99
|
this.json = this.body; // alias
|
|
100
100
|
|
|
101
|
-
// ── files
|
|
101
|
+
// ── files / file ──────────────────────────────────────────────────────────
|
|
102
102
|
// Uploaded files (populated by multer or similar middleware)
|
|
103
|
+
// req.files → multi-file upload { avatar: File, resume: File }
|
|
104
|
+
// req.file → single-file upload (multer .single())
|
|
103
105
|
this.files = millaReq.raw.files || {};
|
|
106
|
+
this.file = millaReq.raw.file || null;
|
|
104
107
|
|
|
105
108
|
// ── headers ───────────────────────────────────────────────────────────────
|
|
106
109
|
this.headers = millaReq.raw.headers || {};
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UploadedFile
|
|
5
|
+
*
|
|
6
|
+
* A rich wrapper around a raw multer file object.
|
|
7
|
+
* Every uploaded file in a Millas route handler is an instance of this class —
|
|
8
|
+
* developers never interact with raw multer objects.
|
|
9
|
+
*
|
|
10
|
+
* ── Handler usage ─────────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* async upload({ file, user }) {
|
|
13
|
+
* // Type checks
|
|
14
|
+
* file.isImage() // true
|
|
15
|
+
* file.isVideo() // false
|
|
16
|
+
* file.extension() // 'jpg'
|
|
17
|
+
* file.mimeType // 'image/jpeg'
|
|
18
|
+
* file.size // 204800 (bytes)
|
|
19
|
+
* file.humanSize() // '200 KB'
|
|
20
|
+
* file.originalName // 'photo.jpg'
|
|
21
|
+
* file.fieldName // 'file'
|
|
22
|
+
*
|
|
23
|
+
* // Store to the default disk — returns the stored path
|
|
24
|
+
* const path = await file.store('avatars');
|
|
25
|
+
* // → 'avatars/1714000000_a3f9bc.jpg'
|
|
26
|
+
*
|
|
27
|
+
* // Store with an explicit filename
|
|
28
|
+
* const path = await file.storeAs('avatars', `${user.id}.jpg`);
|
|
29
|
+
* // → 'avatars/42.jpg'
|
|
30
|
+
*
|
|
31
|
+
* // Store to a specific disk
|
|
32
|
+
* const path = await file.store('avatars', { disk: 's3' });
|
|
33
|
+
* const path = await file.storeAs('avatars', 'photo.jpg', { disk: 's3' });
|
|
34
|
+
*
|
|
35
|
+
* // Public URL after storing
|
|
36
|
+
* const url = file.url('avatars'); // builds URL without storing
|
|
37
|
+
*
|
|
38
|
+
* // Image dimensions (requires sharp — optional)
|
|
39
|
+
* const { width, height } = await file.dimensions();
|
|
40
|
+
*
|
|
41
|
+
* // Convert to base64 data URI
|
|
42
|
+
* const dataUri = file.toDataUri();
|
|
43
|
+
* // → 'data:image/jpeg;base64,/9j/4AAQ...'
|
|
44
|
+
*
|
|
45
|
+
* // Raw buffer access
|
|
46
|
+
* const buffer = file.buffer;
|
|
47
|
+
*
|
|
48
|
+
* return success({ path });
|
|
49
|
+
* }
|
|
50
|
+
*
|
|
51
|
+
* ── Multiple files ────────────────────────────────────────────────────────────
|
|
52
|
+
*
|
|
53
|
+
* async upload({ files }) {
|
|
54
|
+
* // files.photos is an array of UploadedFile when maxCount > 1
|
|
55
|
+
* for (const photo of files.photos) {
|
|
56
|
+
* await photo.store('gallery');
|
|
57
|
+
* }
|
|
58
|
+
* }
|
|
59
|
+
*/
|
|
60
|
+
class UploadedFile {
|
|
61
|
+
/**
|
|
62
|
+
* @param {object} multerFile Raw multer file object
|
|
63
|
+
*/
|
|
64
|
+
constructor(multerFile) {
|
|
65
|
+
if (!multerFile || typeof multerFile !== 'object') {
|
|
66
|
+
throw new Error('[UploadedFile] multerFile must be a multer file object.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @private */
|
|
70
|
+
this._raw = multerFile;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Identity ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/** The form field name this file was uploaded under. */
|
|
76
|
+
get fieldName() { return this._raw.fieldname || ''; }
|
|
77
|
+
|
|
78
|
+
/** Original filename as given by the client (not sanitised — do not trust for storage). */
|
|
79
|
+
get originalName() { return this._raw.originalname || ''; }
|
|
80
|
+
|
|
81
|
+
/** MIME type as reported by the client. */
|
|
82
|
+
get mimeType() { return this._raw.mimetype || ''; }
|
|
83
|
+
|
|
84
|
+
/** File size in bytes. */
|
|
85
|
+
get size() { return this._raw.size || 0; }
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Raw buffer (memory storage only).
|
|
89
|
+
* For disk storage, use file.path and read manually.
|
|
90
|
+
*/
|
|
91
|
+
get buffer() { return this._raw.buffer || null; }
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Absolute path on disk (disk storage only).
|
|
95
|
+
* Null when using memory storage.
|
|
96
|
+
*/
|
|
97
|
+
get diskPath() { return this._raw.path || null; }
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The encoding used for the upload (e.g. '7bit').
|
|
101
|
+
*/
|
|
102
|
+
get encoding() { return this._raw.encoding || ''; }
|
|
103
|
+
|
|
104
|
+
// ── Type helpers ───────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/** Returns true if this is an image file. */
|
|
107
|
+
isImage() {
|
|
108
|
+
return this.mimeType.startsWith('image/');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Returns true if this is a video file. */
|
|
112
|
+
isVideo() {
|
|
113
|
+
return this.mimeType.startsWith('video/');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Returns true if this is an audio file. */
|
|
117
|
+
isAudio() {
|
|
118
|
+
return this.mimeType.startsWith('audio/');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Returns true if this is a PDF. */
|
|
122
|
+
isPdf() {
|
|
123
|
+
return this.mimeType === 'application/pdf';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Returns true if the MIME type matches any of the given types. */
|
|
127
|
+
hasMimeType(...types) {
|
|
128
|
+
const flat = types.flat();
|
|
129
|
+
return flat.includes(this.mimeType);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Name & extension ───────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* File extension derived from the original filename (lowercase, no dot).
|
|
136
|
+
* file.extension() // 'jpg'
|
|
137
|
+
*/
|
|
138
|
+
extension() {
|
|
139
|
+
const name = this.originalName;
|
|
140
|
+
const idx = name.lastIndexOf('.');
|
|
141
|
+
return idx !== -1 ? name.slice(idx + 1).toLowerCase() : '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Original filename without the extension.
|
|
146
|
+
* file.basename() // 'photo'
|
|
147
|
+
*/
|
|
148
|
+
basename() {
|
|
149
|
+
const name = this.originalName;
|
|
150
|
+
const idx = name.lastIndexOf('.');
|
|
151
|
+
return idx !== -1 ? name.slice(0, idx) : name;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Size formatting ────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Human-readable file size string.
|
|
158
|
+
* file.humanSize() // '1.2 MB'
|
|
159
|
+
*
|
|
160
|
+
* @param {number} [decimals=1]
|
|
161
|
+
*/
|
|
162
|
+
humanSize(decimals = 1) {
|
|
163
|
+
const bytes = this.size;
|
|
164
|
+
if (bytes === 0) return '0 B';
|
|
165
|
+
const k = 1024;
|
|
166
|
+
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
167
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
168
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Storage ────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Store the file under a directory, using an auto-generated filename.
|
|
175
|
+
* Returns the stored relative path.
|
|
176
|
+
*
|
|
177
|
+
* const path = await file.store('avatars');
|
|
178
|
+
* // → 'avatars/1714000000_a3f9bc.jpg'
|
|
179
|
+
*
|
|
180
|
+
* const path = await file.store('photos', { disk: 's3' });
|
|
181
|
+
*
|
|
182
|
+
* @param {string} directory
|
|
183
|
+
* @param {object} [options]
|
|
184
|
+
* @param {string} [options.disk] storage disk name (default disk used if omitted)
|
|
185
|
+
* @param {string} [options.contentType] override MIME type header
|
|
186
|
+
* @param {string} [options.acl] S3 ACL override
|
|
187
|
+
* @returns {Promise<string>} stored path
|
|
188
|
+
*/
|
|
189
|
+
async store(directory, options = {}) {
|
|
190
|
+
const Storage = require('../storage/Storage');
|
|
191
|
+
const disk = options.disk ? Storage.disk(options.disk) : Storage;
|
|
192
|
+
return disk.putFile(directory, this._raw, options);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Store the file under a directory with an explicit filename.
|
|
197
|
+
* Returns the stored relative path.
|
|
198
|
+
*
|
|
199
|
+
* const path = await file.storeAs('avatars', `${user.id}.jpg`);
|
|
200
|
+
* // → 'avatars/42.jpg'
|
|
201
|
+
*
|
|
202
|
+
* const path = await file.storeAs('avatars', 'photo.jpg', { disk: 's3' });
|
|
203
|
+
*
|
|
204
|
+
* @param {string} directory
|
|
205
|
+
* @param {string} filename filename including extension
|
|
206
|
+
* @param {object} [options]
|
|
207
|
+
* @param {string} [options.disk]
|
|
208
|
+
* @param {string} [options.contentType]
|
|
209
|
+
* @param {string} [options.acl]
|
|
210
|
+
* @returns {Promise<string>} stored path
|
|
211
|
+
*/
|
|
212
|
+
async storeAs(directory, filename, options = {}) {
|
|
213
|
+
const Storage = require('../storage/Storage');
|
|
214
|
+
const disk = options.disk ? Storage.disk(options.disk) : Storage;
|
|
215
|
+
return disk.putFile(directory, this._raw, { ...options, name: filename.replace(/\.[^.]+$/, '') });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Build the public URL for a path without storing.
|
|
220
|
+
* Useful when you want to compute the URL in advance.
|
|
221
|
+
*
|
|
222
|
+
* const url = file.url('avatars/alice.jpg');
|
|
223
|
+
*
|
|
224
|
+
* @param {string} path
|
|
225
|
+
* @param {string} [disk]
|
|
226
|
+
*/
|
|
227
|
+
url(path, disk) {
|
|
228
|
+
const Storage = require('../storage/Storage');
|
|
229
|
+
return disk ? Storage.disk(disk).url(path) : Storage.url(path);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── Content ────────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Convert the file buffer to a base64-encoded data URI.
|
|
236
|
+
* Memory storage only — throws if buffer is unavailable.
|
|
237
|
+
*
|
|
238
|
+
* file.toDataUri()
|
|
239
|
+
* // → 'data:image/jpeg;base64,/9j/4AAQ...'
|
|
240
|
+
*/
|
|
241
|
+
toDataUri() {
|
|
242
|
+
const buf = this._requireBuffer('toDataUri');
|
|
243
|
+
return `data:${this.mimeType};base64,${buf.toString('base64')}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Convert the file buffer to a raw base64 string.
|
|
248
|
+
* Memory storage only.
|
|
249
|
+
*
|
|
250
|
+
* file.toBase64()
|
|
251
|
+
* // → '/9j/4AAQ...'
|
|
252
|
+
*/
|
|
253
|
+
toBase64() {
|
|
254
|
+
return this._requireBuffer('toBase64').toString('base64');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Read the raw content of the file as a Buffer.
|
|
259
|
+
* Works for both memory and disk storage.
|
|
260
|
+
*
|
|
261
|
+
* @returns {Promise<Buffer>}
|
|
262
|
+
*/
|
|
263
|
+
async read() {
|
|
264
|
+
if (this.buffer) return this.buffer;
|
|
265
|
+
if (this.diskPath) {
|
|
266
|
+
return require('fs-extra').readFile(this.diskPath);
|
|
267
|
+
}
|
|
268
|
+
throw new Error('[UploadedFile] No buffer or disk path available.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── Image helpers ──────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Get the pixel dimensions of an image file.
|
|
275
|
+
* Requires sharp (optional peer dependency).
|
|
276
|
+
*
|
|
277
|
+
* const { width, height } = await file.dimensions();
|
|
278
|
+
*
|
|
279
|
+
* @returns {Promise<{ width: number, height: number }>}
|
|
280
|
+
*/
|
|
281
|
+
async dimensions() {
|
|
282
|
+
if (!this.isImage()) {
|
|
283
|
+
throw new Error('[UploadedFile] dimensions() is only available for image files.');
|
|
284
|
+
}
|
|
285
|
+
let sharp;
|
|
286
|
+
try {
|
|
287
|
+
sharp = require('sharp');
|
|
288
|
+
} catch {
|
|
289
|
+
throw new Error(
|
|
290
|
+
'[UploadedFile] dimensions() requires sharp.\n' +
|
|
291
|
+
'Run: npm install sharp'
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const buf = await this.read();
|
|
295
|
+
const meta = await sharp(buf).metadata();
|
|
296
|
+
return { width: meta.width, height: meta.height };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Get full sharp metadata for an image (width, height, format, space, channels, etc.)
|
|
301
|
+
* Requires sharp.
|
|
302
|
+
*
|
|
303
|
+
* const meta = await file.metadata();
|
|
304
|
+
* // → { width: 1920, height: 1080, format: 'jpeg', space: 'srgb', ... }
|
|
305
|
+
*
|
|
306
|
+
* @returns {Promise<object>}
|
|
307
|
+
*/
|
|
308
|
+
async metadata() {
|
|
309
|
+
let sharp;
|
|
310
|
+
try {
|
|
311
|
+
sharp = require('sharp');
|
|
312
|
+
} catch {
|
|
313
|
+
throw new Error(
|
|
314
|
+
'[UploadedFile] metadata() requires sharp.\n' +
|
|
315
|
+
'Run: npm install sharp'
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const buf = await this.read();
|
|
319
|
+
return sharp(buf).metadata();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── Serialisation ──────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Plain object representation — safe to log or include in responses.
|
|
326
|
+
* Does NOT include the buffer.
|
|
327
|
+
*/
|
|
328
|
+
toJSON() {
|
|
329
|
+
return {
|
|
330
|
+
fieldName: this.fieldName,
|
|
331
|
+
originalName: this.originalName,
|
|
332
|
+
mimeType: this.mimeType,
|
|
333
|
+
size: this.size,
|
|
334
|
+
humanSize: this.humanSize(),
|
|
335
|
+
extension: this.extension(),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** @override */
|
|
340
|
+
toString() {
|
|
341
|
+
return `[UploadedFile: ${this.originalName} (${this.humanSize()})]`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Internal ───────────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
_requireBuffer(method) {
|
|
347
|
+
if (!this.buffer) {
|
|
348
|
+
throw new Error(
|
|
349
|
+
`[UploadedFile] ${method}() requires memory storage. ` +
|
|
350
|
+
'The file was stored to disk — use file.read() instead.'
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
return this.buffer;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Static factory ─────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Wrap a raw multer file object (or an array of them) in UploadedFile.
|
|
360
|
+
* Already-wrapped instances are returned unchanged.
|
|
361
|
+
*
|
|
362
|
+
* @param {object|object[]} raw
|
|
363
|
+
* @returns {UploadedFile|UploadedFile[]}
|
|
364
|
+
*/
|
|
365
|
+
static wrap(raw) {
|
|
366
|
+
if (!raw) return null;
|
|
367
|
+
if (raw instanceof UploadedFile) return raw;
|
|
368
|
+
if (Array.isArray(raw)) return raw.map(UploadedFile.wrap);
|
|
369
|
+
return new UploadedFile(raw);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Wrap the req.file / req.files payload produced by multer into
|
|
374
|
+
* UploadedFile instances. Returns { file, files } ready to be
|
|
375
|
+
* assigned to the RequestContext.
|
|
376
|
+
*
|
|
377
|
+
* @param {object} expressReq Raw Express request after multer has run
|
|
378
|
+
* @returns {{ file: UploadedFile|null, files: object }}
|
|
379
|
+
*/
|
|
380
|
+
static wrapRequest(expressReq) {
|
|
381
|
+
// Single file upload (multer .single())
|
|
382
|
+
const file = expressReq.file ? new UploadedFile(expressReq.file) : null;
|
|
383
|
+
|
|
384
|
+
// Multi-file upload
|
|
385
|
+
let files = {};
|
|
386
|
+
if (expressReq.files) {
|
|
387
|
+
if (Array.isArray(expressReq.files)) {
|
|
388
|
+
// multer .any() → flat array, group by fieldname
|
|
389
|
+
for (const f of expressReq.files) {
|
|
390
|
+
const wrapped = new UploadedFile(f);
|
|
391
|
+
if (!files[f.fieldname]) {
|
|
392
|
+
files[f.fieldname] = wrapped;
|
|
393
|
+
} else if (Array.isArray(files[f.fieldname])) {
|
|
394
|
+
files[f.fieldname].push(wrapped);
|
|
395
|
+
} else {
|
|
396
|
+
files[f.fieldname] = [files[f.fieldname], wrapped];
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
} else {
|
|
400
|
+
// multer .fields() → { fieldname: [multerFile, ...] }
|
|
401
|
+
for (const [fieldname, arr] of Object.entries(expressReq.files)) {
|
|
402
|
+
const wrapped = arr.map(f => new UploadedFile(f));
|
|
403
|
+
files[fieldname] = wrapped.length === 1 ? wrapped[0] : wrapped;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { file, files };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = UploadedFile;
|