@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.
- package/README.md +429 -0
- package/cli/create.js +260 -0
- package/cli/njs.js +164 -0
- package/lib/Auth/Manager.js +111 -0
- package/lib/Build/Manager.js +1232 -0
- package/lib/Console/Commands/BuildCommand.js +25 -0
- package/lib/Console/Commands/DevCommand.js +385 -0
- package/lib/Console/Commands/MakeCommand.js +110 -0
- package/lib/Console/Commands/MigrateCommand.js +98 -0
- package/lib/Console/Commands/MigrateFreshCommand.js +97 -0
- package/lib/Console/Commands/SeedCommand.js +92 -0
- package/lib/Console/Commands/StorageLinkCommand.js +31 -0
- package/lib/Console/Stubs/controller.js +19 -0
- package/lib/Console/Stubs/middleware.js +9 -0
- package/lib/Console/Stubs/migration.js +23 -0
- package/lib/Console/Stubs/model.js +7 -0
- package/lib/Console/Stubs/page-hydration.tsx +54 -0
- package/lib/Console/Stubs/seeder.js +9 -0
- package/lib/Console/Stubs/vendor.tsx +11 -0
- package/lib/Core/Config.js +86 -0
- package/lib/Core/Environment.js +21 -0
- package/lib/Core/Paths.js +188 -0
- package/lib/Database/Connection.js +61 -0
- package/lib/Database/DB.js +84 -0
- package/lib/Database/Drivers/MySQLDriver.js +234 -0
- package/lib/Database/Manager.js +162 -0
- package/lib/Database/Model.js +161 -0
- package/lib/Database/QueryBuilder.js +714 -0
- package/lib/Database/QueryValidation.js +62 -0
- package/lib/Database/Schema/Blueprint.js +126 -0
- package/lib/Database/Schema/Manager.js +116 -0
- package/lib/Date/DateTime.js +108 -0
- package/lib/Date/Locale.js +68 -0
- package/lib/Encryption/Manager.js +47 -0
- package/lib/Filesystem/Manager.js +49 -0
- package/lib/Hashing/Manager.js +25 -0
- package/lib/Http/Server.js +317 -0
- package/lib/Logging/Manager.js +153 -0
- package/lib/Mail/Manager.js +120 -0
- package/lib/Route/Loader.js +81 -0
- package/lib/Route/Manager.js +265 -0
- package/lib/Runtime/Entry.js +11 -0
- package/lib/Session/File.js +299 -0
- package/lib/Session/Manager.js +259 -0
- package/lib/Session/Memory.js +67 -0
- package/lib/Session/Session.js +196 -0
- package/lib/Support/Str.js +100 -0
- package/lib/Translation/Manager.js +49 -0
- package/lib/Validation/MimeTypes.js +39 -0
- package/lib/Validation/Validator.js +691 -0
- package/lib/View/Manager.js +544 -0
- package/lib/View/Templates/default/Home.tsx +262 -0
- package/lib/View/Templates/default/MainLayout.tsx +44 -0
- package/lib/View/Templates/errors/404.tsx +13 -0
- package/lib/View/Templates/errors/500.tsx +13 -0
- package/lib/View/Templates/errors/ErrorLayout.tsx +112 -0
- package/lib/View/Templates/messages/Maintenance.tsx +17 -0
- package/lib/View/Templates/messages/MessageLayout.tsx +136 -0
- package/lib/index.js +57 -0
- package/package.json +47 -0
- package/skeleton/.env.example +26 -0
- package/skeleton/app/Controllers/HomeController.js +9 -0
- package/skeleton/app/Kernel.js +11 -0
- package/skeleton/app/Middlewares/Authentication.js +9 -0
- package/skeleton/app/Middlewares/Guest.js +9 -0
- package/skeleton/app/Middlewares/VerifyCsrf.js +24 -0
- package/skeleton/app/Models/User.js +7 -0
- package/skeleton/config/app.js +4 -0
- package/skeleton/config/auth.js +16 -0
- package/skeleton/config/database.js +27 -0
- package/skeleton/config/hash.js +3 -0
- package/skeleton/config/server.js +28 -0
- package/skeleton/config/session.js +21 -0
- package/skeleton/database/migrations/2025_01_01_00_00_users.js +20 -0
- package/skeleton/database/seeders/UserSeeder.js +15 -0
- package/skeleton/globals.d.ts +1 -0
- package/skeleton/package.json +24 -0
- package/skeleton/public/.gitkeep +0 -0
- package/skeleton/resources/css/.gitkeep +0 -0
- package/skeleton/resources/langs/.gitkeep +0 -0
- package/skeleton/resources/views/Site/Home.tsx +66 -0
- package/skeleton/routes/web.js +4 -0
- package/skeleton/storage/app/private/.gitkeep +0 -0
- package/skeleton/storage/app/public/.gitkeep +0 -0
- package/skeleton/storage/framework/sessions/.gitkeep +0 -0
- package/skeleton/storage/logs/.gitkeep +0 -0
- 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;
|