@nitronjs/framework 0.1.0

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.
Files changed (87) hide show
  1. package/README.md +429 -0
  2. package/cli/create.js +260 -0
  3. package/cli/njs.js +164 -0
  4. package/lib/Auth/Manager.js +111 -0
  5. package/lib/Build/Manager.js +1232 -0
  6. package/lib/Console/Commands/BuildCommand.js +25 -0
  7. package/lib/Console/Commands/DevCommand.js +385 -0
  8. package/lib/Console/Commands/MakeCommand.js +110 -0
  9. package/lib/Console/Commands/MigrateCommand.js +98 -0
  10. package/lib/Console/Commands/MigrateFreshCommand.js +97 -0
  11. package/lib/Console/Commands/SeedCommand.js +92 -0
  12. package/lib/Console/Commands/StorageLinkCommand.js +31 -0
  13. package/lib/Console/Stubs/controller.js +19 -0
  14. package/lib/Console/Stubs/middleware.js +9 -0
  15. package/lib/Console/Stubs/migration.js +23 -0
  16. package/lib/Console/Stubs/model.js +7 -0
  17. package/lib/Console/Stubs/page-hydration.tsx +54 -0
  18. package/lib/Console/Stubs/seeder.js +9 -0
  19. package/lib/Console/Stubs/vendor.tsx +11 -0
  20. package/lib/Core/Config.js +86 -0
  21. package/lib/Core/Environment.js +21 -0
  22. package/lib/Core/Paths.js +188 -0
  23. package/lib/Database/Connection.js +61 -0
  24. package/lib/Database/DB.js +84 -0
  25. package/lib/Database/Drivers/MySQLDriver.js +234 -0
  26. package/lib/Database/Manager.js +162 -0
  27. package/lib/Database/Model.js +161 -0
  28. package/lib/Database/QueryBuilder.js +714 -0
  29. package/lib/Database/QueryValidation.js +62 -0
  30. package/lib/Database/Schema/Blueprint.js +126 -0
  31. package/lib/Database/Schema/Manager.js +116 -0
  32. package/lib/Date/DateTime.js +108 -0
  33. package/lib/Date/Locale.js +68 -0
  34. package/lib/Encryption/Manager.js +47 -0
  35. package/lib/Filesystem/Manager.js +49 -0
  36. package/lib/Hashing/Manager.js +25 -0
  37. package/lib/Http/Server.js +317 -0
  38. package/lib/Logging/Manager.js +153 -0
  39. package/lib/Mail/Manager.js +120 -0
  40. package/lib/Route/Loader.js +81 -0
  41. package/lib/Route/Manager.js +265 -0
  42. package/lib/Runtime/Entry.js +11 -0
  43. package/lib/Session/File.js +299 -0
  44. package/lib/Session/Manager.js +259 -0
  45. package/lib/Session/Memory.js +67 -0
  46. package/lib/Session/Session.js +196 -0
  47. package/lib/Support/Str.js +100 -0
  48. package/lib/Translation/Manager.js +49 -0
  49. package/lib/Validation/MimeTypes.js +39 -0
  50. package/lib/Validation/Validator.js +691 -0
  51. package/lib/View/Manager.js +544 -0
  52. package/lib/View/Templates/default/Home.tsx +262 -0
  53. package/lib/View/Templates/default/MainLayout.tsx +44 -0
  54. package/lib/View/Templates/errors/404.tsx +13 -0
  55. package/lib/View/Templates/errors/500.tsx +13 -0
  56. package/lib/View/Templates/errors/ErrorLayout.tsx +112 -0
  57. package/lib/View/Templates/messages/Maintenance.tsx +17 -0
  58. package/lib/View/Templates/messages/MessageLayout.tsx +136 -0
  59. package/lib/index.js +57 -0
  60. package/package.json +47 -0
  61. package/skeleton/.env.example +26 -0
  62. package/skeleton/app/Controllers/HomeController.js +9 -0
  63. package/skeleton/app/Kernel.js +11 -0
  64. package/skeleton/app/Middlewares/Authentication.js +9 -0
  65. package/skeleton/app/Middlewares/Guest.js +9 -0
  66. package/skeleton/app/Middlewares/VerifyCsrf.js +24 -0
  67. package/skeleton/app/Models/User.js +7 -0
  68. package/skeleton/config/app.js +4 -0
  69. package/skeleton/config/auth.js +16 -0
  70. package/skeleton/config/database.js +27 -0
  71. package/skeleton/config/hash.js +3 -0
  72. package/skeleton/config/server.js +28 -0
  73. package/skeleton/config/session.js +21 -0
  74. package/skeleton/database/migrations/2025_01_01_00_00_users.js +20 -0
  75. package/skeleton/database/seeders/UserSeeder.js +15 -0
  76. package/skeleton/globals.d.ts +1 -0
  77. package/skeleton/package.json +24 -0
  78. package/skeleton/public/.gitkeep +0 -0
  79. package/skeleton/resources/css/.gitkeep +0 -0
  80. package/skeleton/resources/langs/.gitkeep +0 -0
  81. package/skeleton/resources/views/Site/Home.tsx +66 -0
  82. package/skeleton/routes/web.js +4 -0
  83. package/skeleton/storage/app/private/.gitkeep +0 -0
  84. package/skeleton/storage/app/public/.gitkeep +0 -0
  85. package/skeleton/storage/framework/sessions/.gitkeep +0 -0
  86. package/skeleton/storage/logs/.gitkeep +0 -0
  87. package/skeleton/tsconfig.json +33 -0
@@ -0,0 +1,691 @@
1
+ import { MIME_TYPES } from "./MimeTypes.js";
2
+
3
+ /**
4
+ * Laravel-style Validator for NitronJS
5
+ *
6
+ * BEHAVIOR:
7
+ * - All rules are OPTIONAL by default (skip validation for absent values)
8
+ * - Use "required" to make a field mandatory
9
+ * - Use "nullable" to explicitly allow null (skips ALL validation)
10
+ *
11
+ * SECURITY WARNING:
12
+ * File validation (mimes, mime_types) checks MIME headers only.
13
+ * This is NOT secure - attackers can spoof MIME types.
14
+ * Always verify file signatures (magic bytes) server-side before storage.
15
+ *
16
+ * UNITS:
17
+ * - min/max for files: BYTES (e.g., max:2097152 = 2MB)
18
+ * - min/max for strings: characters
19
+ * - min/max for arrays: item count
20
+ * - min/max for numbers: numeric value
21
+ */
22
+ class Validator {
23
+ #data;
24
+ #rules;
25
+ #customMessages;
26
+ #errors = {};
27
+
28
+ static #MIME_TYPES = MIME_TYPES;
29
+
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+ // PUBLIC API
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+
34
+ /**
35
+ * Create validator and run validation
36
+ *
37
+ * @param {Object} data - Request data to validate
38
+ * @param {Object} rules - Validation rules per field
39
+ * @param {Object} customMessages - Optional custom error messages
40
+ */
41
+ static make(data, rules, customMessages = {}) {
42
+ const validator = new Validator();
43
+
44
+ validator.#data = this.#normalizeBracketNotation(data);
45
+ validator.#rules = rules;
46
+ validator.#customMessages = customMessages;
47
+ validator.#runValidation();
48
+
49
+ return validator;
50
+ }
51
+
52
+ /**
53
+ * Convert bracket notation keys to nested object structure
54
+ * Example: { "users[0][name]": "John" } → { users: [{ name: "John" }] }
55
+ */
56
+ static #normalizeBracketNotation(data) {
57
+ if (!data || typeof data !== "object") {
58
+ return data;
59
+ }
60
+
61
+ const hasBrackets = Object.keys(data).some(key => key.includes("["));
62
+ if (!hasBrackets) {
63
+ return data;
64
+ }
65
+
66
+ const result = {};
67
+
68
+ for (const [key, value] of Object.entries(data)) {
69
+ if (!key.includes("[")) {
70
+ result[key] = value;
71
+ continue;
72
+ }
73
+
74
+ const parts = key.replace(/\]/g, "").split("[");
75
+ let current = result;
76
+
77
+ for (let i = 0; i < parts.length - 1; i++) {
78
+ const part = parts[i];
79
+ const nextPart = parts[i + 1];
80
+ const isNextIndex = /^\d+$/.test(nextPart);
81
+
82
+ if (current[part] === undefined) {
83
+ current[part] = isNextIndex ? [] : {};
84
+ }
85
+
86
+ current = current[part];
87
+ }
88
+
89
+ const lastPart = parts[parts.length - 1];
90
+ current[lastPart] = value;
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ /** Check if validation failed */
97
+ fails() {
98
+ return Object.keys(this.#errors).length > 0;
99
+ }
100
+
101
+ /** Check if validation passed */
102
+ passes() {
103
+ return !this.fails();
104
+ }
105
+
106
+ /** Get all errors (returns immutable copy) */
107
+ errors() {
108
+ return JSON.parse(JSON.stringify(this.#errors));
109
+ }
110
+
111
+ /** Get first error for a specific field */
112
+ first(field) {
113
+ return this.#errors[field]?.[0] || null;
114
+ }
115
+
116
+ // ═══════════════════════════════════════════════════════════════════════════
117
+ // HELPER METHODS
118
+ // ═══════════════════════════════════════════════════════════════════════════
119
+
120
+ /**
121
+ * Check if value is absent (undefined, null, or empty string)
122
+ * Used to skip optional field validation
123
+ */
124
+ #isAbsent(value) {
125
+ return value === undefined || value === null || value === "";
126
+ }
127
+
128
+ /**
129
+ * Extract file information from Frontend File/Blob or Backend Multipart
130
+ * Returns null if not a valid file object
131
+ *
132
+ * @returns {{ mime: string, size: number | null } | null}
133
+ */
134
+ #getFileInfo(value) {
135
+ if (!value || typeof value !== "object") {
136
+ return null;
137
+ }
138
+
139
+ // Backend Multipart (Fastify @fastify/multipart)
140
+ if (typeof value.mimetype === "string" && typeof value.toBuffer === "function") {
141
+ return {
142
+ mime: value.mimetype.toLowerCase(),
143
+ size: value._buf?.length ?? null
144
+ };
145
+ }
146
+
147
+ // Frontend File/Blob (Browser API)
148
+ if (typeof value.type === "string" && value.type.includes("/") && typeof value.size === "number") {
149
+ return {
150
+ mime: value.type.toLowerCase(),
151
+ size: value.size
152
+ };
153
+ }
154
+
155
+ return null;
156
+ }
157
+
158
+ /**
159
+ * Format bytes to human-readable string
160
+ */
161
+ #formatBytes(bytes) {
162
+ if (bytes < 1024) return `${bytes} bytes`;
163
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
164
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
165
+ }
166
+
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+ // CORE VALIDATION ENGINE
169
+ // ═══════════════════════════════════════════════════════════════════════════
170
+
171
+ /**
172
+ * Run all validations
173
+ * Handles both regular fields and wildcard patterns
174
+ */
175
+ #runValidation() {
176
+ for (const [pattern, rulesString] of Object.entries(this.#rules)) {
177
+ if (pattern.includes("*")) {
178
+ this.#validateWildcard(pattern, rulesString);
179
+ } else {
180
+ this.#validateField(pattern, this.#data[pattern], rulesString);
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Validate a single field against its rules
187
+ * Short-circuits on first failure for better UX
188
+ */
189
+ #validateField(field, value, rulesString) {
190
+ const rules = rulesString.split("|").map(r => r.trim());
191
+
192
+ // Nullable + null = skip ALL validation (including required)
193
+ if (rules.includes("nullable") && value === null) {
194
+ return;
195
+ }
196
+
197
+ for (const rule of rules) {
198
+ const result = this.#applyRule(field, value, rule);
199
+
200
+ if (!result.passes) {
201
+ // Initialize error array if needed
202
+ if (!this.#errors[field]) {
203
+ this.#errors[field] = [];
204
+ }
205
+
206
+ this.#errors[field].push(result.message);
207
+
208
+ // Short-circuit: stop on first failure
209
+ break;
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Validate wildcard patterns (e.g., users.*.email)
216
+ * Expands pattern to actual paths and validates each
217
+ */
218
+ #validateWildcard(pattern, rulesString) {
219
+ const paths = this.#expandWildcard(pattern);
220
+
221
+ for (const path of paths) {
222
+ const value = this.#getNestedValue(path);
223
+
224
+ // Format error key: users.0.email → users[0].email
225
+ const errorKey = path.replace(/\.(\d+)/g, '[$1]');
226
+
227
+ this.#validateField(errorKey, value, rulesString);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Expand wildcard pattern to actual array indices
233
+ * Example: users.*.email → [users.0.email, users.1.email, ...]
234
+ */
235
+ #expandWildcard(pattern) {
236
+ const parts = pattern.split(".");
237
+ let paths = [""];
238
+
239
+ for (const part of parts) {
240
+ const newPaths = [];
241
+
242
+ for (const current of paths) {
243
+ if (part === "*") {
244
+ const arr = current ? this.#getNestedValue(current) : this.#data;
245
+
246
+ if (Array.isArray(arr)) {
247
+ for (let i = 0; i < arr.length; i++) {
248
+ const newPath = current ? `${current}.${i}` : String(i);
249
+ newPaths.push(newPath);
250
+ }
251
+ }
252
+ } else {
253
+ const newPath = current ? `${current}.${part}` : part;
254
+ newPaths.push(newPath);
255
+ }
256
+ }
257
+
258
+ paths = newPaths;
259
+ }
260
+
261
+ return paths;
262
+ }
263
+
264
+ /**
265
+ * Get value from nested object path
266
+ * Example: "users.0.email" → data.users[0].email
267
+ */
268
+ #getNestedValue(path) {
269
+ return path.split(".").reduce((obj, key) => obj?.[key], this.#data);
270
+ }
271
+
272
+ /**
273
+ * Apply a single validation rule
274
+ * Handles rule parsing, execution, and custom messages
275
+ */
276
+ #applyRule(field, value, rule) {
277
+ // Parse rule: "min:3" → name="min", params=["3"]
278
+ const [name, ...paramParts] = rule.split(":");
279
+ const params = paramParts.length ? paramParts.join(":").split(",") : [];
280
+
281
+ const handler = this.#ruleHandlers[name];
282
+
283
+ // Unknown rule = developer error, fail fast
284
+ if (!handler) {
285
+ throw new Error(`Unknown validation rule: "${name}". Check your rule definitions.`);
286
+ }
287
+
288
+ const result = handler.call(this, value, params, field);
289
+
290
+ // Apply custom messages (field-specific > global > default)
291
+ if (!result.passes) {
292
+ const fieldSpecificKey = `${field}.${name}`;
293
+
294
+ if (this.#customMessages[fieldSpecificKey]) {
295
+ result.message = this.#customMessages[fieldSpecificKey];
296
+ } else if (this.#customMessages[name]) {
297
+ result.message = this.#customMessages[name];
298
+ }
299
+ }
300
+
301
+ return result;
302
+ }
303
+
304
+ // ═══════════════════════════════════════════════════════════════════════════
305
+ // VALIDATION RULES
306
+ // ═══════════════════════════════════════════════════════════════════════════
307
+
308
+ #ruleHandlers = {
309
+
310
+ // ─── Presence Rules ──────────────────────────────────────────────────
311
+
312
+ /** Field must exist and not be empty/whitespace */
313
+ required: (value, params, field) => {
314
+ const isEmpty = value === null ||
315
+ value === undefined ||
316
+ value === "" ||
317
+ (typeof value === "string" && value.trim() === "");
318
+
319
+ return {
320
+ passes: !isEmpty,
321
+ message: `The ${field} field is required.`
322
+ };
323
+ },
324
+
325
+ /** Allow null to skip all validation */
326
+ nullable: () => {
327
+ return { passes: true, message: "" };
328
+ },
329
+
330
+ // ─── Type Rules ──────────────────────────────────────────────────────
331
+
332
+ /** Must be a string */
333
+ string: (value, params, field) => {
334
+ if (this.#isAbsent(value)) {
335
+ return { passes: true, message: "" };
336
+ }
337
+
338
+ return {
339
+ passes: typeof value === "string",
340
+ message: `The ${field} field must be a string.`
341
+ };
342
+ },
343
+
344
+ /** Must be an array or object */
345
+ array: (value, params, field) => {
346
+ if (this.#isAbsent(value)) {
347
+ return { passes: true, message: "" };
348
+ }
349
+
350
+ const isArrayOrObject = Array.isArray(value) ||
351
+ (value !== null && typeof value === "object");
352
+
353
+ return {
354
+ passes: isArrayOrObject,
355
+ message: `The ${field} field must be an array.`
356
+ };
357
+ },
358
+
359
+ /** Must be numeric (integer or decimal) */
360
+ numeric: (value, params, field) => {
361
+ if (this.#isAbsent(value)) {
362
+ return { passes: true, message: "" };
363
+ }
364
+
365
+ const passes = /^-?\d+(\.\d+)?$/.test(String(value).trim());
366
+
367
+ return {
368
+ passes,
369
+ message: `The ${field} field must be numeric.`
370
+ };
371
+ },
372
+
373
+ /** Must be an integer */
374
+ integer: (value, params, field) => {
375
+ if (this.#isAbsent(value)) {
376
+ return { passes: true, message: "" };
377
+ }
378
+
379
+ const passes = /^-?\d+$/.test(String(value).trim());
380
+
381
+ return {
382
+ passes,
383
+ message: `The ${field} field must be an integer.`
384
+ };
385
+ },
386
+
387
+ /** Must be boolean-like (true/false/1/0/"on"/"off") */
388
+ boolean: (value, params, field) => {
389
+ const booleanValues = [true, false, "true", "false", 1, 0, "1", "0", "on", "off"];
390
+
391
+ return {
392
+ passes: booleanValues.includes(value),
393
+ message: `The ${field} field must be true or false.`
394
+ };
395
+ },
396
+
397
+ // ─── Format Rules ────────────────────────────────────────────────────
398
+
399
+ /** Must be valid email format */
400
+ email: (value, params, field) => {
401
+ if (this.#isAbsent(value)) {
402
+ return { passes: true, message: "" };
403
+ }
404
+
405
+ const passes = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
406
+
407
+ return {
408
+ passes,
409
+ message: `The ${field} field must be a valid email address.`
410
+ };
411
+ },
412
+
413
+ /** Must be valid URL */
414
+ url: (value, params, field) => {
415
+ if (this.#isAbsent(value)) {
416
+ return { passes: true, message: "" };
417
+ }
418
+
419
+ try {
420
+ new URL(value);
421
+ return { passes: true, message: "" };
422
+ } catch {
423
+ return {
424
+ passes: false,
425
+ message: `The ${field} field must be a valid URL.`
426
+ };
427
+ }
428
+ },
429
+
430
+ /** Only letters (a-z, A-Z) */
431
+ alpha: (value, params, field) => {
432
+ if (this.#isAbsent(value)) {
433
+ return { passes: true, message: "" };
434
+ }
435
+
436
+ return {
437
+ passes: /^[a-zA-Z]+$/.test(value),
438
+ message: `The ${field} field must contain only letters.`
439
+ };
440
+ },
441
+
442
+ /** Only letters and numbers */
443
+ alpha_num: (value, params, field) => {
444
+ if (this.#isAbsent(value)) {
445
+ return { passes: true, message: "" };
446
+ }
447
+
448
+ return {
449
+ passes: /^[a-zA-Z0-9]+$/.test(value),
450
+ message: `The ${field} field must contain only letters and numbers.`
451
+ };
452
+ },
453
+
454
+ /** Letters, numbers, dashes, underscores (good for slugs/usernames) */
455
+ alpha_dash: (value, params, field) => {
456
+ if (this.#isAbsent(value)) {
457
+ return { passes: true, message: "" };
458
+ }
459
+
460
+ return {
461
+ passes: /^[a-zA-Z0-9_-]+$/.test(value),
462
+ message: `The ${field} field must contain only letters, numbers, dashes and underscores.`
463
+ };
464
+ },
465
+
466
+ // ─── Comparison Rules ────────────────────────────────────────────────
467
+
468
+ /** Must be in allowed list. Usage: in:admin,user,guest */
469
+ in: (value, params, field) => {
470
+ if (this.#isAbsent(value)) {
471
+ return { passes: true, message: "" };
472
+ }
473
+
474
+ return {
475
+ passes: params.includes(String(value)),
476
+ message: `The ${field} field must be one of: ${params.join(", ")}.`
477
+ };
478
+ },
479
+
480
+ /** Must match {field}_confirmation */
481
+ confirmed: (value, params, field) => {
482
+ const confirmationField = `${field}_confirmation`;
483
+ const passes = value === this.#data[confirmationField];
484
+
485
+ return {
486
+ passes,
487
+ message: `The ${field} confirmation does not match.`
488
+ };
489
+ },
490
+
491
+ // ─── Size Rules ──────────────────────────────────────────────────────
492
+
493
+ /**
494
+ * Minimum size/length/value
495
+ * - Arrays: minimum item count
496
+ * - Strings: minimum character count
497
+ * - Files: minimum size in BYTES
498
+ * - Numbers: minimum value
499
+ */
500
+ min: (value, params, field) => {
501
+ if (this.#isAbsent(value)) {
502
+ return { passes: true, message: "" };
503
+ }
504
+
505
+ const min = parseFloat(params[0]);
506
+
507
+ // Array: item count
508
+ if (Array.isArray(value)) {
509
+ return {
510
+ passes: value.length >= min,
511
+ message: `The ${field} must have at least ${min} items.`
512
+ };
513
+ }
514
+
515
+ // Object: property count
516
+ if (value !== null && typeof value === "object") {
517
+ const keyCount = Object.keys(value).length;
518
+ return {
519
+ passes: keyCount >= min,
520
+ message: `The ${field} must have at least ${min} items.`
521
+ };
522
+ }
523
+
524
+ // File: size in bytes
525
+ const file = this.#getFileInfo(value);
526
+ if (file && file.size !== null) {
527
+ return {
528
+ passes: file.size >= min,
529
+ message: `The ${field} file must be at least ${this.#formatBytes(min)}.`
530
+ };
531
+ }
532
+
533
+ // String: character count
534
+ if (typeof value === "string") {
535
+ return {
536
+ passes: value.length >= min,
537
+ message: `The ${field} field must be at least ${min} characters.`
538
+ };
539
+ }
540
+
541
+ // Number: numeric value
542
+ if (!isNaN(value)) {
543
+ return {
544
+ passes: parseFloat(value) >= min,
545
+ message: `The ${field} field must be at least ${min}.`
546
+ };
547
+ }
548
+
549
+ return {
550
+ passes: false,
551
+ message: `The ${field} field cannot be validated with min rule.`
552
+ };
553
+ },
554
+
555
+ /**
556
+ * Maximum size/length/value
557
+ * - Arrays: maximum item count
558
+ * - Strings: maximum character count
559
+ * - Files: maximum size in BYTES
560
+ * - Numbers: maximum value
561
+ */
562
+ max: (value, params, field) => {
563
+ if (this.#isAbsent(value)) {
564
+ return { passes: true, message: "" };
565
+ }
566
+
567
+ const max = parseFloat(params[0]);
568
+
569
+ // Array: item count
570
+ if (Array.isArray(value)) {
571
+ return {
572
+ passes: value.length <= max,
573
+ message: `The ${field} must not have more than ${max} items.`
574
+ };
575
+ }
576
+
577
+ // Object: property count
578
+ if (value !== null && typeof value === "object") {
579
+ const keyCount = Object.keys(value).length;
580
+ return {
581
+ passes: keyCount <= max,
582
+ message: `The ${field} must not have more than ${max} items.`
583
+ };
584
+ }
585
+
586
+ // File: size in bytes
587
+ const file = this.#getFileInfo(value);
588
+ if (file && file.size !== null) {
589
+ return {
590
+ passes: file.size <= max,
591
+ message: `The ${field} file must not exceed ${this.#formatBytes(max)}.`
592
+ };
593
+ }
594
+
595
+ // String: character count
596
+ if (typeof value === "string") {
597
+ return {
598
+ passes: value.length <= max,
599
+ message: `The ${field} field must not exceed ${max} characters.`
600
+ };
601
+ }
602
+
603
+ // Number: numeric value
604
+ if (!isNaN(value)) {
605
+ return {
606
+ passes: parseFloat(value) <= max,
607
+ message: `The ${field} field must not exceed ${max}.`
608
+ };
609
+ }
610
+
611
+ return {
612
+ passes: false,
613
+ message: `The ${field} field cannot be validated with max rule.`
614
+ };
615
+ },
616
+
617
+ // ─── File Rules ──────────────────────────────────────────────────────
618
+ // ⚠️ WARNING: These rules check MIME headers only, which can be spoofed.
619
+ // Always verify file signatures (magic bytes) server-side!
620
+
621
+ /** Must be a valid file object */
622
+ file: (value, params, field) => {
623
+ if (value === undefined || value === null) {
624
+ return { passes: true, message: "" };
625
+ }
626
+
627
+ return {
628
+ passes: this.#getFileInfo(value) !== null,
629
+ message: `The ${field} must be a file.`
630
+ };
631
+ },
632
+
633
+ /** File must have allowed MIME type. Usage: mime_types:image/jpeg,image/png */
634
+ mime_types: (value, params, field) => {
635
+ if (value === undefined || value === null) {
636
+ return { passes: true, message: "" };
637
+ }
638
+
639
+ const file = this.#getFileInfo(value);
640
+
641
+ if (!file) {
642
+ return {
643
+ passes: false,
644
+ message: `The ${field} must be a valid file.`
645
+ };
646
+ }
647
+
648
+ const allowed = params.map(m => m.trim().toLowerCase());
649
+
650
+ return {
651
+ passes: allowed.includes(file.mime),
652
+ message: `The ${field} file must be one of the following types: ${allowed.join(", ")}.`
653
+ };
654
+ },
655
+
656
+ /** File must have allowed extension. Usage: mimes:png,jpg,pdf */
657
+ mimes: (value, params, field) => {
658
+ if (value === undefined || value === null) {
659
+ return { passes: true, message: "" };
660
+ }
661
+
662
+ const file = this.#getFileInfo(value);
663
+
664
+ if (!file) {
665
+ return {
666
+ passes: false,
667
+ message: `The ${field} must be a valid file.`
668
+ };
669
+ }
670
+
671
+ // Convert extensions to MIME types
672
+ const allowed = params
673
+ .map(ext => Validator.#MIME_TYPES[ext.trim().toLowerCase()])
674
+ .filter(Boolean);
675
+
676
+ if (allowed.length === 0) {
677
+ return {
678
+ passes: false,
679
+ message: `Invalid file extensions provided for ${field}.`
680
+ };
681
+ }
682
+
683
+ return {
684
+ passes: allowed.includes(file.mime),
685
+ message: `The ${field} file must be one of: ${params.join(", ")}.`
686
+ };
687
+ }
688
+ };
689
+ }
690
+
691
+ export default Validator;