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.
@@ -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;
@@ -397,8 +397,14 @@ class FileValidator extends BaseValidator {
397
397
  }
398
398
 
399
399
  _checkType(value) {
400
- // Files come through as multer file objects just check it's present
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?.size || v.size <= bytes,
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?.mimetype || allowed.includes(v.mimetype),
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?.mimetype || imageTypes.includes(v.mimetype),
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);