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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "millas",
3
- "version": "0.2.15",
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
 
@@ -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;