millas 0.2.14 → 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/orm/model/Model.js +6 -1
- 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
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* S3Driver
|
|
5
|
+
*
|
|
6
|
+
* AWS S3 (and S3-compatible) storage driver for Millas.
|
|
7
|
+
* Implements the same interface as LocalDriver so Storage.put/get/url/etc.
|
|
8
|
+
* work identically in production — no application code changes needed.
|
|
9
|
+
*
|
|
10
|
+
* ── Installation ──────────────────────────────────────────────────────────────
|
|
11
|
+
*
|
|
12
|
+
* npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
13
|
+
*
|
|
14
|
+
* ── Configuration (config/storage.js) ────────────────────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* module.exports = {
|
|
17
|
+
* default: process.env.STORAGE_DRIVER || 'local',
|
|
18
|
+
* disks: {
|
|
19
|
+
* local: {
|
|
20
|
+
* driver: 'local',
|
|
21
|
+
* root: 'storage/uploads',
|
|
22
|
+
* baseUrl: '/storage',
|
|
23
|
+
* },
|
|
24
|
+
* s3: {
|
|
25
|
+
* driver: 's3',
|
|
26
|
+
* bucket: process.env.AWS_BUCKET,
|
|
27
|
+
* region: process.env.AWS_REGION || 'us-east-1',
|
|
28
|
+
* accessKey: process.env.AWS_ACCESS_KEY_ID,
|
|
29
|
+
* secretKey: process.env.AWS_SECRET_ACCESS_KEY,
|
|
30
|
+
* endpoint: process.env.AWS_ENDPOINT || null, // for S3-compatible (R2, MinIO, etc.)
|
|
31
|
+
* baseUrl: process.env.AWS_BASE_URL || null, // custom CDN / public URL prefix
|
|
32
|
+
* acl: process.env.AWS_ACL || 'private', // 'private' | 'public-read'
|
|
33
|
+
* },
|
|
34
|
+
* },
|
|
35
|
+
* };
|
|
36
|
+
*
|
|
37
|
+
* ── Usage ──────────────────────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* // Set STORAGE_DRIVER=s3 in .env — everything else is identical:
|
|
40
|
+
* await Storage.put('avatars/alice.jpg', buffer);
|
|
41
|
+
* const url = Storage.url('avatars/alice.jpg');
|
|
42
|
+
* const buf = await Storage.get('avatars/alice.jpg');
|
|
43
|
+
*
|
|
44
|
+
* // Explicit disk selection:
|
|
45
|
+
* await Storage.disk('s3').put('reports/q3.pdf', pdfBuffer);
|
|
46
|
+
*
|
|
47
|
+
* // Signed URLs (time-limited private access):
|
|
48
|
+
* const signedUrl = await Storage.disk('s3').signedUrl('private/doc.pdf', { expiresIn: 3600 });
|
|
49
|
+
*
|
|
50
|
+
* ── S3-compatible services ────────────────────────────────────────────────────
|
|
51
|
+
*
|
|
52
|
+
* Cloudflare R2: endpoint: 'https://<account>.r2.cloudflarestorage.com'
|
|
53
|
+
* MinIO: endpoint: 'http://localhost:9000'
|
|
54
|
+
* DigitalOcean: endpoint: 'https://<region>.digitaloceanspaces.com'
|
|
55
|
+
* Backblaze B2: endpoint: 'https://s3.<region>.backblazeb2.com'
|
|
56
|
+
*/
|
|
57
|
+
class S3Driver {
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} config
|
|
60
|
+
* @param {string} config.bucket S3 bucket name
|
|
61
|
+
* @param {string} [config.region] AWS region (default: us-east-1)
|
|
62
|
+
* @param {string} [config.accessKey] AWS_ACCESS_KEY_ID
|
|
63
|
+
* @param {string} [config.secretKey] AWS_SECRET_ACCESS_KEY
|
|
64
|
+
* @param {string} [config.endpoint] Custom endpoint for S3-compatible services
|
|
65
|
+
* @param {string} [config.baseUrl] Public URL prefix (CDN / public bucket)
|
|
66
|
+
* @param {string} [config.acl] Default ACL ('private' | 'public-read')
|
|
67
|
+
* @param {string} [config.prefix] Optional key prefix for all stored files
|
|
68
|
+
*/
|
|
69
|
+
constructor(config = {}) {
|
|
70
|
+
if (!config.bucket) {
|
|
71
|
+
throw new Error('[S3Driver] config.bucket is required.');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
this._bucket = config.bucket;
|
|
75
|
+
this._region = config.region || 'us-east-1';
|
|
76
|
+
this._baseUrl = config.baseUrl || null;
|
|
77
|
+
this._acl = config.acl || 'private';
|
|
78
|
+
this._prefix = config.prefix ? config.prefix.replace(/\/$/, '') + '/' : '';
|
|
79
|
+
this._endpoint = config.endpoint || null;
|
|
80
|
+
|
|
81
|
+
// Credentials — fall back to env vars / IAM role if not provided
|
|
82
|
+
this._credentials = config.accessKey && config.secretKey
|
|
83
|
+
? { accessKeyId: config.accessKey, secretAccessKey: config.secretKey }
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
this._client = null; // lazy
|
|
87
|
+
this._signer = null; // lazy
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Core Operations ───────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Upload a file to S3. Returns the stored path (relative, no prefix).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} filePath relative path (e.g. 'avatars/alice.jpg')
|
|
96
|
+
* @param {Buffer|string} content
|
|
97
|
+
* @param {object} [options]
|
|
98
|
+
* @param {string} [options.acl] override default ACL
|
|
99
|
+
* @param {string} [options.contentType] explicit MIME type
|
|
100
|
+
* @param {object} [options.metadata] extra S3 metadata
|
|
101
|
+
*/
|
|
102
|
+
async put(filePath, content, options = {}) {
|
|
103
|
+
const { PutObjectCommand } = this._sdk();
|
|
104
|
+
const key = this._key(filePath);
|
|
105
|
+
|
|
106
|
+
const params = {
|
|
107
|
+
Bucket: this._bucket,
|
|
108
|
+
Key: key,
|
|
109
|
+
Body: typeof content === 'string' ? Buffer.from(content) : content,
|
|
110
|
+
ContentType: options.contentType || this._mime(filePath),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (options.acl || this._acl !== 'private') {
|
|
114
|
+
params.ACL = options.acl || this._acl;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options.metadata) {
|
|
118
|
+
params.Metadata = options.metadata;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
await this._client.send(new PutObjectCommand(params));
|
|
122
|
+
return filePath;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Download a file from S3. Returns a Buffer.
|
|
127
|
+
*/
|
|
128
|
+
async get(filePath) {
|
|
129
|
+
const { GetObjectCommand } = this._sdk();
|
|
130
|
+
const response = await this._client.send(new GetObjectCommand({
|
|
131
|
+
Bucket: this._bucket,
|
|
132
|
+
Key: this._key(filePath),
|
|
133
|
+
}));
|
|
134
|
+
return _streamToBuffer(response.Body);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read a file as a UTF-8 string.
|
|
139
|
+
*/
|
|
140
|
+
async getString(filePath) {
|
|
141
|
+
const buf = await this.get(filePath);
|
|
142
|
+
return buf.toString('utf8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Check if a file exists in S3.
|
|
147
|
+
*/
|
|
148
|
+
async exists(filePath) {
|
|
149
|
+
const { HeadObjectCommand } = this._sdk();
|
|
150
|
+
try {
|
|
151
|
+
await this._client.send(new HeadObjectCommand({
|
|
152
|
+
Bucket: this._bucket,
|
|
153
|
+
Key: this._key(filePath),
|
|
154
|
+
}));
|
|
155
|
+
return true;
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) return false;
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Delete a single file from S3.
|
|
164
|
+
*/
|
|
165
|
+
async delete(filePath) {
|
|
166
|
+
const { DeleteObjectCommand } = this._sdk();
|
|
167
|
+
await this._client.send(new DeleteObjectCommand({
|
|
168
|
+
Bucket: this._bucket,
|
|
169
|
+
Key: this._key(filePath),
|
|
170
|
+
}));
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Delete all objects under a directory prefix.
|
|
176
|
+
*/
|
|
177
|
+
async deleteDirectory(dirPath) {
|
|
178
|
+
const keys = await this._listKeys(dirPath);
|
|
179
|
+
if (!keys.length) return true;
|
|
180
|
+
|
|
181
|
+
const { DeleteObjectsCommand } = this._sdk();
|
|
182
|
+
// S3 DeleteObjects accepts up to 1000 keys per request
|
|
183
|
+
for (let i = 0; i < keys.length; i += 1000) {
|
|
184
|
+
const batch = keys.slice(i, i + 1000).map(Key => ({ Key }));
|
|
185
|
+
await this._client.send(new DeleteObjectsCommand({
|
|
186
|
+
Bucket: this._bucket,
|
|
187
|
+
Delete: { Objects: batch, Quiet: true },
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Copy a file within S3.
|
|
195
|
+
*/
|
|
196
|
+
async copy(from, to) {
|
|
197
|
+
const { CopyObjectCommand } = this._sdk();
|
|
198
|
+
await this._client.send(new CopyObjectCommand({
|
|
199
|
+
Bucket: this._bucket,
|
|
200
|
+
CopySource: `${this._bucket}/${this._key(from)}`,
|
|
201
|
+
Key: this._key(to),
|
|
202
|
+
}));
|
|
203
|
+
return to;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Move a file within S3 (copy then delete).
|
|
208
|
+
*/
|
|
209
|
+
async move(from, to) {
|
|
210
|
+
await this.copy(from, to);
|
|
211
|
+
await this.delete(from);
|
|
212
|
+
return to;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* List files directly under a directory prefix (non-recursive).
|
|
217
|
+
*/
|
|
218
|
+
async files(dirPath = '') {
|
|
219
|
+
const { ListObjectsV2Command } = this._sdk();
|
|
220
|
+
const prefix = this._key(dirPath ? dirPath.replace(/\/$/, '') + '/' : '');
|
|
221
|
+
const response = await this._client.send(new ListObjectsV2Command({
|
|
222
|
+
Bucket: this._bucket,
|
|
223
|
+
Prefix: prefix,
|
|
224
|
+
Delimiter: '/',
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
return (response.Contents || [])
|
|
228
|
+
.map(obj => this._stripPrefix(obj.Key))
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* List all files under a directory prefix (recursive).
|
|
234
|
+
*/
|
|
235
|
+
async allFiles(dirPath = '') {
|
|
236
|
+
const keys = await this._listKeys(dirPath);
|
|
237
|
+
return keys.map(k => this._stripPrefix(k)).filter(Boolean);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* List all directory "folders" directly under a prefix.
|
|
242
|
+
*/
|
|
243
|
+
async directories(dirPath = '') {
|
|
244
|
+
const { ListObjectsV2Command } = this._sdk();
|
|
245
|
+
const prefix = this._key(dirPath ? dirPath.replace(/\/$/, '') + '/' : '');
|
|
246
|
+
const response = await this._client.send(new ListObjectsV2Command({
|
|
247
|
+
Bucket: this._bucket,
|
|
248
|
+
Prefix: prefix,
|
|
249
|
+
Delimiter: '/',
|
|
250
|
+
}));
|
|
251
|
+
|
|
252
|
+
return (response.CommonPrefixes || [])
|
|
253
|
+
.map(p => this._stripPrefix(p.Prefix).replace(/\/$/, ''))
|
|
254
|
+
.filter(Boolean);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* No-op for S3 — directories are virtual. Kept for interface parity.
|
|
259
|
+
*/
|
|
260
|
+
async makeDirectory(_dirPath) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get file metadata from S3 (HeadObject).
|
|
266
|
+
*/
|
|
267
|
+
async metadata(filePath) {
|
|
268
|
+
const { HeadObjectCommand } = this._sdk();
|
|
269
|
+
const response = await this._client.send(new HeadObjectCommand({
|
|
270
|
+
Bucket: this._bucket,
|
|
271
|
+
Key: this._key(filePath),
|
|
272
|
+
}));
|
|
273
|
+
return {
|
|
274
|
+
path: filePath,
|
|
275
|
+
size: response.ContentLength,
|
|
276
|
+
mimeType: response.ContentType,
|
|
277
|
+
lastModified: response.LastModified,
|
|
278
|
+
etag: response.ETag,
|
|
279
|
+
metadata: response.Metadata || {},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Get the public URL for a file.
|
|
285
|
+
*
|
|
286
|
+
* If config.baseUrl is set (e.g. a CDN), uses that.
|
|
287
|
+
* Otherwise constructs the standard S3 URL.
|
|
288
|
+
*/
|
|
289
|
+
url(filePath) {
|
|
290
|
+
if (this._baseUrl) {
|
|
291
|
+
return `${this._baseUrl.replace(/\/$/, '')}/${filePath}`.replace(/([^:]\/)\/+/g, '$1');
|
|
292
|
+
}
|
|
293
|
+
const key = this._key(filePath);
|
|
294
|
+
if (this._endpoint) {
|
|
295
|
+
return `${this._endpoint.replace(/\/$/, '')}/${this._bucket}/${key}`;
|
|
296
|
+
}
|
|
297
|
+
return `https://${this._bucket}.s3.${this._region}.amazonaws.com/${key}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get the S3 key path (not a local filesystem path).
|
|
302
|
+
* Included for interface parity — returns the S3 key.
|
|
303
|
+
*/
|
|
304
|
+
path(filePath) {
|
|
305
|
+
return this._key(filePath);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Generate a pre-signed URL for temporary private access.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} filePath
|
|
312
|
+
* @param {object} [options]
|
|
313
|
+
* @param {number} [options.expiresIn=3600] seconds until expiry
|
|
314
|
+
* @param {string} [options.disposition] Content-Disposition header value
|
|
315
|
+
*/
|
|
316
|
+
async signedUrl(filePath, options = {}) {
|
|
317
|
+
const { GetObjectCommand } = this._sdk();
|
|
318
|
+
const { getSignedUrl } = this._presigner();
|
|
319
|
+
|
|
320
|
+
const commandParams = {
|
|
321
|
+
Bucket: this._bucket,
|
|
322
|
+
Key: this._key(filePath),
|
|
323
|
+
};
|
|
324
|
+
if (options.disposition) {
|
|
325
|
+
commandParams.ResponseContentDisposition = options.disposition;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return getSignedUrl(
|
|
329
|
+
this._client,
|
|
330
|
+
new GetObjectCommand(commandParams),
|
|
331
|
+
{ expiresIn: options.expiresIn || 3600 }
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Stream a file to an Express response.
|
|
337
|
+
* Falls back to buffering if streaming is not supported by the SDK version.
|
|
338
|
+
*/
|
|
339
|
+
async stream(filePath, res, options = {}) {
|
|
340
|
+
const { GetObjectCommand } = this._sdk();
|
|
341
|
+
const response = await this._client.send(new GetObjectCommand({
|
|
342
|
+
Bucket: this._bucket,
|
|
343
|
+
Key: this._key(filePath),
|
|
344
|
+
}));
|
|
345
|
+
|
|
346
|
+
if (options.download) {
|
|
347
|
+
res.setHeader('Content-Disposition', `attachment; filename="${require('path').basename(filePath)}"`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (response.ContentType) res.setHeader('Content-Type', response.ContentType);
|
|
351
|
+
if (response.ContentLength) res.setHeader('Content-Length', response.ContentLength);
|
|
352
|
+
|
|
353
|
+
if (typeof response.Body?.pipe === 'function') {
|
|
354
|
+
response.Body.pipe(res);
|
|
355
|
+
} else {
|
|
356
|
+
const buf = await _streamToBuffer(response.Body);
|
|
357
|
+
res.end(buf);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Build and cache the S3Client. Lazy so the SDK is only imported
|
|
365
|
+
* if S3Driver is actually used.
|
|
366
|
+
*/
|
|
367
|
+
_sdk() {
|
|
368
|
+
if (this._client) return require('@aws-sdk/client-s3');
|
|
369
|
+
|
|
370
|
+
let S3Client, sdkModule;
|
|
371
|
+
try {
|
|
372
|
+
sdkModule = require('@aws-sdk/client-s3');
|
|
373
|
+
S3Client = sdkModule.S3Client;
|
|
374
|
+
} catch {
|
|
375
|
+
throw new Error(
|
|
376
|
+
'[S3Driver] @aws-sdk/client-s3 is not installed.\n' +
|
|
377
|
+
'Run: npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner'
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const clientConfig = { region: this._region };
|
|
382
|
+
if (this._credentials) clientConfig.credentials = this._credentials;
|
|
383
|
+
if (this._endpoint) clientConfig.endpoint = this._endpoint;
|
|
384
|
+
// Required for path-style URLs on S3-compatible services (MinIO, R2, etc.)
|
|
385
|
+
if (this._endpoint) clientConfig.forcePathStyle = true;
|
|
386
|
+
|
|
387
|
+
this._client = new S3Client(clientConfig);
|
|
388
|
+
return sdkModule;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_presigner() {
|
|
392
|
+
try {
|
|
393
|
+
return require('@aws-sdk/s3-request-presigner');
|
|
394
|
+
} catch {
|
|
395
|
+
throw new Error(
|
|
396
|
+
'[S3Driver] @aws-sdk/s3-request-presigner is not installed.\n' +
|
|
397
|
+
'Run: npm install @aws-sdk/s3-request-presigner'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Prepend the configured key prefix. */
|
|
403
|
+
_key(filePath) {
|
|
404
|
+
return `${this._prefix}${filePath}`.replace(/^\//, '');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Strip the configured prefix from a raw S3 key. */
|
|
408
|
+
_stripPrefix(key) {
|
|
409
|
+
return this._prefix ? key.replace(new RegExp(`^${_escapeRegex(this._prefix)}`), '') : key;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** List all S3 keys (paginated) under a directory prefix. */
|
|
413
|
+
async _listKeys(dirPath = '') {
|
|
414
|
+
const { ListObjectsV2Command } = this._sdk();
|
|
415
|
+
const prefix = this._key(dirPath ? dirPath.replace(/\/$/, '') + '/' : '');
|
|
416
|
+
const keys = [];
|
|
417
|
+
let token;
|
|
418
|
+
|
|
419
|
+
do {
|
|
420
|
+
const params = { Bucket: this._bucket, Prefix: prefix };
|
|
421
|
+
if (token) params.ContinuationToken = token;
|
|
422
|
+
|
|
423
|
+
const response = await this._client.send(new ListObjectsV2Command(params));
|
|
424
|
+
(response.Contents || []).forEach(obj => keys.push(obj.Key));
|
|
425
|
+
token = response.IsTruncated ? response.NextContinuationToken : null;
|
|
426
|
+
} while (token);
|
|
427
|
+
|
|
428
|
+
return keys;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_mime(filePath) {
|
|
432
|
+
const ext = require('path').extname(filePath).toLowerCase();
|
|
433
|
+
const types = {
|
|
434
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
435
|
+
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
436
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain',
|
|
437
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
|
438
|
+
'.json': 'application/json', '.zip': 'application/zip',
|
|
439
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
|
440
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
441
|
+
'.doc': 'application/msword',
|
|
442
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
443
|
+
'.xls': 'application/vnd.ms-excel',
|
|
444
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
445
|
+
};
|
|
446
|
+
return types[ext] || 'application/octet-stream';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
async function _streamToBuffer(stream) {
|
|
453
|
+
if (Buffer.isBuffer(stream)) return stream;
|
|
454
|
+
if (typeof stream?.transformToByteArray === 'function') {
|
|
455
|
+
// AWS SDK v3 streaming body
|
|
456
|
+
const arr = await stream.transformToByteArray();
|
|
457
|
+
return Buffer.from(arr);
|
|
458
|
+
}
|
|
459
|
+
return new Promise((resolve, reject) => {
|
|
460
|
+
const chunks = [];
|
|
461
|
+
stream.on('data', chunk => chunks.push(chunk));
|
|
462
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
463
|
+
stream.on('error', reject);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function _escapeRegex(str) {
|
|
468
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
module.exports = S3Driver;
|
package/src/validation/types.js
CHANGED
|
@@ -397,8 +397,14 @@ class FileValidator extends BaseValidator {
|
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
_checkType(value) {
|
|
400
|
-
//
|
|
400
|
+
// Accept both raw multer file objects and Millas UploadedFile instances.
|
|
401
|
+
// We check for the UploadedFile brand first (via constructor name or
|
|
402
|
+
// the presence of fieldName / mimeType getters), then fall back to the
|
|
403
|
+
// plain object check for raw multer objects (which have .fieldname).
|
|
401
404
|
if (!value || typeof value !== 'object') return this._typeError || 'Must be a file';
|
|
405
|
+
const isUploadedFile = value.constructor?.name === 'UploadedFile';
|
|
406
|
+
const isMulterFile = typeof value.fieldname === 'string' || typeof value.fieldName === 'string';
|
|
407
|
+
if (!isUploadedFile && !isMulterFile) return this._typeError || 'Must be a file';
|
|
402
408
|
return null;
|
|
403
409
|
}
|
|
404
410
|
|
|
@@ -410,7 +416,7 @@ class FileValidator extends BaseValidator {
|
|
|
410
416
|
const bytes = typeof size === 'number' ? size : _parseSize(size);
|
|
411
417
|
this._maxSizeBytes = bytes;
|
|
412
418
|
return this._addRule(
|
|
413
|
-
v => !v
|
|
419
|
+
v => !_fileSize(v) || _fileSize(v) <= bytes,
|
|
414
420
|
msg || `File must not exceed ${size}`
|
|
415
421
|
);
|
|
416
422
|
}
|
|
@@ -424,7 +430,7 @@ class FileValidator extends BaseValidator {
|
|
|
424
430
|
const allowed = Array.isArray(types) ? types : [types];
|
|
425
431
|
this._mimeTypes = allowed;
|
|
426
432
|
return this._addRule(
|
|
427
|
-
v => !v
|
|
433
|
+
v => !_fileMime(v) || allowed.includes(_fileMime(v)),
|
|
428
434
|
msg || `Must be one of: ${allowed.join(', ')}`
|
|
429
435
|
);
|
|
430
436
|
}
|
|
@@ -441,12 +447,27 @@ class FileValidator extends BaseValidator {
|
|
|
441
447
|
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
442
448
|
this._mimeTypes = imageTypes;
|
|
443
449
|
return this._addRule(
|
|
444
|
-
v => !v
|
|
450
|
+
v => !_fileMime(v) || imageTypes.includes(_fileMime(v)),
|
|
445
451
|
msg || 'Must be an image (jpeg, png, gif, webp, svg)'
|
|
446
452
|
);
|
|
447
453
|
}
|
|
448
454
|
}
|
|
449
455
|
|
|
456
|
+
// ── File accessor helpers ──────────────────────────────────────────────────────
|
|
457
|
+
// Work with both UploadedFile instances (camelCase) and raw multer objects
|
|
458
|
+
// (lowercase .mimetype / .fieldname) so validation rules are future-proof.
|
|
459
|
+
|
|
460
|
+
function _fileMime(v) {
|
|
461
|
+
if (!v) return null;
|
|
462
|
+
// UploadedFile exposes .mimeType (getter), raw multer uses .mimetype
|
|
463
|
+
return v.mimeType ?? v.mimetype ?? null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function _fileSize(v) {
|
|
467
|
+
if (!v) return null;
|
|
468
|
+
return v.size ?? null;
|
|
469
|
+
}
|
|
470
|
+
|
|
450
471
|
function _parseSize(str) {
|
|
451
472
|
const s = String(str).toLowerCase().trim();
|
|
452
473
|
const n = parseFloat(s);
|