voltjs-framework 1.0.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/LICENSE +21 -0
- package/README.md +1265 -0
- package/bin/volt.js +139 -0
- package/package.json +56 -0
- package/src/api/graphql.js +399 -0
- package/src/api/rest.js +204 -0
- package/src/api/websocket.js +285 -0
- package/src/cli/build.js +111 -0
- package/src/cli/create.js +371 -0
- package/src/cli/db.js +106 -0
- package/src/cli/dev.js +114 -0
- package/src/cli/generate.js +278 -0
- package/src/cli/lint.js +172 -0
- package/src/cli/routes.js +118 -0
- package/src/cli/start.js +42 -0
- package/src/cli/test.js +138 -0
- package/src/core/app.js +701 -0
- package/src/core/config.js +232 -0
- package/src/core/middleware.js +133 -0
- package/src/core/plugins.js +88 -0
- package/src/core/react-renderer.js +244 -0
- package/src/core/renderer.js +337 -0
- package/src/core/router.js +183 -0
- package/src/database/index.js +461 -0
- package/src/database/migration.js +192 -0
- package/src/database/model.js +285 -0
- package/src/database/query.js +394 -0
- package/src/database/seeder.js +89 -0
- package/src/index.js +156 -0
- package/src/security/auth.js +425 -0
- package/src/security/cors.js +80 -0
- package/src/security/csrf.js +125 -0
- package/src/security/encryption.js +110 -0
- package/src/security/helmet.js +103 -0
- package/src/security/index.js +75 -0
- package/src/security/rateLimit.js +119 -0
- package/src/security/sanitizer.js +113 -0
- package/src/security/xss.js +110 -0
- package/src/ui/component.js +224 -0
- package/src/ui/reactive.js +503 -0
- package/src/ui/template.js +448 -0
- package/src/utils/cache.js +216 -0
- package/src/utils/collection.js +772 -0
- package/src/utils/cron.js +213 -0
- package/src/utils/date.js +223 -0
- package/src/utils/events.js +181 -0
- package/src/utils/excel.js +482 -0
- package/src/utils/form.js +547 -0
- package/src/utils/hash.js +121 -0
- package/src/utils/http.js +461 -0
- package/src/utils/logger.js +186 -0
- package/src/utils/mail.js +347 -0
- package/src/utils/paginator.js +179 -0
- package/src/utils/pdf.js +417 -0
- package/src/utils/queue.js +199 -0
- package/src/utils/schema.js +985 -0
- package/src/utils/sms.js +243 -0
- package/src/utils/storage.js +348 -0
- package/src/utils/string.js +236 -0
- package/src/utils/validation.js +318 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Form Handler
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive form state management — like React Hook Form + Zod combined.
|
|
5
|
+
* Tracks dirty/touched/pristine fields, per-field errors, nested objects, arrays,
|
|
6
|
+
* schema validation, and handles submission.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const { Form } = require('voltjs');
|
|
10
|
+
*
|
|
11
|
+
* const form = new Form({
|
|
12
|
+
* defaults: { name: '', email: '', age: 0 },
|
|
13
|
+
* rules: {
|
|
14
|
+
* name: 'required|string|min:2',
|
|
15
|
+
* email: 'required|email',
|
|
16
|
+
* age: 'required|integer|min:18',
|
|
17
|
+
* },
|
|
18
|
+
* onSubmit: async (data) => await User.create(data),
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* form.set('name', 'John');
|
|
22
|
+
* form.set('email', 'john@example.com');
|
|
23
|
+
* form.set('age', 25);
|
|
24
|
+
*
|
|
25
|
+
* const result = await form.submit();
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
class Form {
|
|
31
|
+
constructor(config = {}) {
|
|
32
|
+
this._defaults = JSON.parse(JSON.stringify(config.defaults || {}));
|
|
33
|
+
this._values = JSON.parse(JSON.stringify(this._defaults));
|
|
34
|
+
this._rules = config.rules || {};
|
|
35
|
+
this._schema = config.schema || null; // VoltSchema instance
|
|
36
|
+
this._onSubmit = config.onSubmit || null;
|
|
37
|
+
this._onError = config.onError || null;
|
|
38
|
+
this._transform = config.transform || null;
|
|
39
|
+
|
|
40
|
+
// Field state
|
|
41
|
+
this._dirty = new Set();
|
|
42
|
+
this._touched = new Set();
|
|
43
|
+
this._errors = {};
|
|
44
|
+
this._submitted = false;
|
|
45
|
+
this._submitting = false;
|
|
46
|
+
this._submitCount = 0;
|
|
47
|
+
this._valid = true;
|
|
48
|
+
|
|
49
|
+
// Watchers
|
|
50
|
+
this._watchers = {};
|
|
51
|
+
this._globalWatchers = [];
|
|
52
|
+
|
|
53
|
+
// Validate mode
|
|
54
|
+
this._validateOn = config.validateOn || 'submit'; // 'change' | 'blur' | 'submit'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ===== GETTERS =====
|
|
58
|
+
|
|
59
|
+
/** Get a field value (supports dot notation: "address.city") */
|
|
60
|
+
get(field) {
|
|
61
|
+
return Form._getPath(this._values, field);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Get all form values */
|
|
65
|
+
get values() {
|
|
66
|
+
return JSON.parse(JSON.stringify(this._values));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get all errors */
|
|
70
|
+
get errors() {
|
|
71
|
+
return { ...this._errors };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Get error for a specific field */
|
|
75
|
+
error(field) {
|
|
76
|
+
return this._errors[field] || null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Check if form is valid */
|
|
80
|
+
get isValid() {
|
|
81
|
+
return this._valid && Object.keys(this._errors).length === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Check if form has been modified */
|
|
85
|
+
get isDirty() {
|
|
86
|
+
return this._dirty.size > 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Check if a specific field is dirty */
|
|
90
|
+
isFieldDirty(field) {
|
|
91
|
+
return this._dirty.has(field);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Check if a specific field has been touched (focused then blurred) */
|
|
95
|
+
isFieldTouched(field) {
|
|
96
|
+
return this._touched.has(field);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Check if form is currently submitting */
|
|
100
|
+
get isSubmitting() {
|
|
101
|
+
return this._submitting;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Number of times form was submitted */
|
|
105
|
+
get submitCount() {
|
|
106
|
+
return this._submitCount;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Check if form has been submitted at least once */
|
|
110
|
+
get isSubmitted() {
|
|
111
|
+
return this._submitted;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Get list of dirty fields */
|
|
115
|
+
get dirtyFields() {
|
|
116
|
+
return [...this._dirty];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Get list of touched fields */
|
|
120
|
+
get touchedFields() {
|
|
121
|
+
return [...this._touched];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Get only the changed values (dirty fields) */
|
|
125
|
+
get dirtyValues() {
|
|
126
|
+
const result = {};
|
|
127
|
+
for (const field of this._dirty) {
|
|
128
|
+
result[field] = this.get(field);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ===== SETTERS =====
|
|
134
|
+
|
|
135
|
+
/** Set a field value (supports dot notation) */
|
|
136
|
+
set(field, value) {
|
|
137
|
+
const oldValue = this.get(field);
|
|
138
|
+
Form._setPath(this._values, field, value);
|
|
139
|
+
|
|
140
|
+
// Track dirty
|
|
141
|
+
const defaultVal = Form._getPath(this._defaults, field);
|
|
142
|
+
if (!Form._isEqual(value, defaultVal)) {
|
|
143
|
+
this._dirty.add(field);
|
|
144
|
+
} else {
|
|
145
|
+
this._dirty.delete(field);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Notify watchers
|
|
149
|
+
this._notifyWatchers(field, value, oldValue);
|
|
150
|
+
|
|
151
|
+
// Validate on change if configured
|
|
152
|
+
if (this._validateOn === 'change') {
|
|
153
|
+
this.validateField(field);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Set multiple fields at once */
|
|
160
|
+
setValues(data) {
|
|
161
|
+
for (const [field, value] of Object.entries(data)) {
|
|
162
|
+
this.set(field, value);
|
|
163
|
+
}
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Mark a field as touched */
|
|
168
|
+
touch(field) {
|
|
169
|
+
this._touched.add(field);
|
|
170
|
+
if (this._validateOn === 'blur') {
|
|
171
|
+
this.validateField(field);
|
|
172
|
+
}
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mark all fields as touched */
|
|
177
|
+
touchAll() {
|
|
178
|
+
const allFields = Form._flattenKeys(this._values);
|
|
179
|
+
for (const field of allFields) {
|
|
180
|
+
this._touched.add(field);
|
|
181
|
+
}
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ===== VALIDATION =====
|
|
186
|
+
|
|
187
|
+
/** Validate the entire form */
|
|
188
|
+
validate() {
|
|
189
|
+
this._errors = {};
|
|
190
|
+
this._valid = true;
|
|
191
|
+
|
|
192
|
+
// Use schema if provided
|
|
193
|
+
if (this._schema && typeof this._schema.validate === 'function') {
|
|
194
|
+
const result = this._schema.validate(this._values);
|
|
195
|
+
if (!result.valid) {
|
|
196
|
+
this._errors = result.errors || {};
|
|
197
|
+
this._valid = false;
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Use rules (string-based validation like Validator)
|
|
204
|
+
if (Object.keys(this._rules).length > 0) {
|
|
205
|
+
try {
|
|
206
|
+
const { Validator } = require('./validation');
|
|
207
|
+
const result = Validator.validate(this._values, this._rules);
|
|
208
|
+
if (!result.valid) {
|
|
209
|
+
this._errors = result.errors;
|
|
210
|
+
this._valid = false;
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// Validator not available, skip
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Custom validators
|
|
219
|
+
if (this._customValidators) {
|
|
220
|
+
for (const [field, fn] of Object.entries(this._customValidators)) {
|
|
221
|
+
const error = fn(this.get(field), this._values);
|
|
222
|
+
if (error) {
|
|
223
|
+
this._errors[field] = Array.isArray(error) ? error : [error];
|
|
224
|
+
this._valid = false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return this._valid;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Validate a single field */
|
|
233
|
+
validateField(field) {
|
|
234
|
+
// Use schema if provided
|
|
235
|
+
if (this._schema && typeof this._schema.validateField === 'function') {
|
|
236
|
+
const result = this._schema.validateField(field, this.get(field));
|
|
237
|
+
if (!result.valid) {
|
|
238
|
+
this._errors[field] = result.errors;
|
|
239
|
+
} else {
|
|
240
|
+
delete this._errors[field];
|
|
241
|
+
}
|
|
242
|
+
this._valid = Object.keys(this._errors).length === 0;
|
|
243
|
+
return !this._errors[field];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Use rules
|
|
247
|
+
if (this._rules[field]) {
|
|
248
|
+
try {
|
|
249
|
+
const { Validator } = require('./validation');
|
|
250
|
+
const result = Validator.validate({ [field]: this.get(field) }, { [field]: this._rules[field] });
|
|
251
|
+
if (!result.valid) {
|
|
252
|
+
this._errors[field] = result.errors[field];
|
|
253
|
+
} else {
|
|
254
|
+
delete this._errors[field];
|
|
255
|
+
}
|
|
256
|
+
} catch { /* skip */ }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Custom validator
|
|
260
|
+
if (this._customValidators?.[field]) {
|
|
261
|
+
const error = this._customValidators[field](this.get(field), this._values);
|
|
262
|
+
if (error) {
|
|
263
|
+
this._errors[field] = Array.isArray(error) ? error : [error];
|
|
264
|
+
} else {
|
|
265
|
+
delete this._errors[field];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
this._valid = Object.keys(this._errors).length === 0;
|
|
270
|
+
return !this._errors[field];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Add a custom field validator */
|
|
274
|
+
addValidator(field, fn) {
|
|
275
|
+
if (!this._customValidators) this._customValidators = {};
|
|
276
|
+
this._customValidators[field] = fn;
|
|
277
|
+
return this;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Set a custom error on a field */
|
|
281
|
+
setError(field, message) {
|
|
282
|
+
this._errors[field] = Array.isArray(message) ? message : [message];
|
|
283
|
+
this._valid = false;
|
|
284
|
+
return this;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Clear error(s) */
|
|
288
|
+
clearError(field) {
|
|
289
|
+
if (field) {
|
|
290
|
+
delete this._errors[field];
|
|
291
|
+
} else {
|
|
292
|
+
this._errors = {};
|
|
293
|
+
}
|
|
294
|
+
this._valid = Object.keys(this._errors).length === 0;
|
|
295
|
+
return this;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ===== SUBMISSION =====
|
|
299
|
+
|
|
300
|
+
/** Submit the form */
|
|
301
|
+
async submit(handler) {
|
|
302
|
+
this._submitCount++;
|
|
303
|
+
this._submitted = true;
|
|
304
|
+
this.touchAll();
|
|
305
|
+
|
|
306
|
+
// Validate
|
|
307
|
+
const valid = this.validate();
|
|
308
|
+
if (!valid) {
|
|
309
|
+
if (this._onError) await this._onError(this._errors, this._values);
|
|
310
|
+
return { success: false, errors: this._errors };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Transform data
|
|
314
|
+
let data = this.values;
|
|
315
|
+
if (this._transform) {
|
|
316
|
+
data = this._transform(data);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Submit
|
|
320
|
+
const submitFn = handler || this._onSubmit;
|
|
321
|
+
if (!submitFn) return { success: true, data };
|
|
322
|
+
|
|
323
|
+
this._submitting = true;
|
|
324
|
+
try {
|
|
325
|
+
const result = await submitFn(data);
|
|
326
|
+
this._submitting = false;
|
|
327
|
+
return { success: true, data, result };
|
|
328
|
+
} catch (err) {
|
|
329
|
+
this._submitting = false;
|
|
330
|
+
|
|
331
|
+
// If server returns field errors, apply them
|
|
332
|
+
if (err.errors && typeof err.errors === 'object') {
|
|
333
|
+
for (const [field, msg] of Object.entries(err.errors)) {
|
|
334
|
+
this._errors[field] = Array.isArray(msg) ? msg : [msg];
|
|
335
|
+
}
|
|
336
|
+
this._valid = false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (this._onError) await this._onError(this._errors, err);
|
|
340
|
+
return { success: false, errors: this._errors, error: err };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ===== RESET & UTILITIES =====
|
|
345
|
+
|
|
346
|
+
/** Reset form to defaults */
|
|
347
|
+
reset(newDefaults) {
|
|
348
|
+
if (newDefaults) {
|
|
349
|
+
this._defaults = JSON.parse(JSON.stringify(newDefaults));
|
|
350
|
+
}
|
|
351
|
+
this._values = JSON.parse(JSON.stringify(this._defaults));
|
|
352
|
+
this._dirty.clear();
|
|
353
|
+
this._touched.clear();
|
|
354
|
+
this._errors = {};
|
|
355
|
+
this._valid = true;
|
|
356
|
+
this._submitted = false;
|
|
357
|
+
this._submitting = false;
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Reset a single field to its default */
|
|
362
|
+
resetField(field) {
|
|
363
|
+
const defaultVal = Form._getPath(this._defaults, field);
|
|
364
|
+
Form._setPath(this._values, field, JSON.parse(JSON.stringify(defaultVal)));
|
|
365
|
+
this._dirty.delete(field);
|
|
366
|
+
this._touched.delete(field);
|
|
367
|
+
delete this._errors[field];
|
|
368
|
+
return this;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Watch a field for changes */
|
|
372
|
+
watch(field, fn) {
|
|
373
|
+
if (typeof field === 'function') {
|
|
374
|
+
// Watch all changes
|
|
375
|
+
this._globalWatchers.push(field);
|
|
376
|
+
return () => {
|
|
377
|
+
this._globalWatchers = this._globalWatchers.filter(w => w !== field);
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (!this._watchers[field]) this._watchers[field] = [];
|
|
381
|
+
this._watchers[field].push(fn);
|
|
382
|
+
return () => {
|
|
383
|
+
this._watchers[field] = this._watchers[field].filter(w => w !== fn);
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Get form state summary */
|
|
388
|
+
getState() {
|
|
389
|
+
return {
|
|
390
|
+
values: this.values,
|
|
391
|
+
errors: this.errors,
|
|
392
|
+
isDirty: this.isDirty,
|
|
393
|
+
isValid: this.isValid,
|
|
394
|
+
isSubmitting: this.isSubmitting,
|
|
395
|
+
isSubmitted: this.isSubmitted,
|
|
396
|
+
submitCount: this.submitCount,
|
|
397
|
+
dirtyFields: this.dirtyFields,
|
|
398
|
+
touchedFields: this.touchedFields,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ===== ARRAY FIELDS =====
|
|
403
|
+
|
|
404
|
+
/** Append an item to an array field */
|
|
405
|
+
append(field, value) {
|
|
406
|
+
const arr = this.get(field) || [];
|
|
407
|
+
if (!Array.isArray(arr)) throw new Error(`${field} is not an array`);
|
|
408
|
+
this.set(field, [...arr, value]);
|
|
409
|
+
return this;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Prepend an item to an array field */
|
|
413
|
+
prepend(field, value) {
|
|
414
|
+
const arr = this.get(field) || [];
|
|
415
|
+
if (!Array.isArray(arr)) throw new Error(`${field} is not an array`);
|
|
416
|
+
this.set(field, [value, ...arr]);
|
|
417
|
+
return this;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Remove an item from an array field by index */
|
|
421
|
+
remove(field, index) {
|
|
422
|
+
const arr = this.get(field) || [];
|
|
423
|
+
if (!Array.isArray(arr)) throw new Error(`${field} is not an array`);
|
|
424
|
+
this.set(field, arr.filter((_, i) => i !== index));
|
|
425
|
+
return this;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Move an array item */
|
|
429
|
+
move(field, from, to) {
|
|
430
|
+
const arr = [...(this.get(field) || [])];
|
|
431
|
+
const [item] = arr.splice(from, 1);
|
|
432
|
+
arr.splice(to, 0, item);
|
|
433
|
+
this.set(field, arr);
|
|
434
|
+
return this;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Swap two array items */
|
|
438
|
+
swap(field, indexA, indexB) {
|
|
439
|
+
const arr = [...(this.get(field) || [])];
|
|
440
|
+
[arr[indexA], arr[indexB]] = [arr[indexB], arr[indexA]];
|
|
441
|
+
this.set(field, arr);
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ===== MIDDLEWARE (Express/Volt) =====
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Express/Volt middleware that parses and validates a form from request body
|
|
449
|
+
*
|
|
450
|
+
* @example
|
|
451
|
+
* app.post('/register', Form.handle({
|
|
452
|
+
* rules: { name: 'required', email: 'required|email' },
|
|
453
|
+
* onSubmit: async (data) => User.create(data),
|
|
454
|
+
* }), (req, res) => {
|
|
455
|
+
* res.json(req.formResult);
|
|
456
|
+
* });
|
|
457
|
+
*/
|
|
458
|
+
static handle(config) {
|
|
459
|
+
return async (req, res) => {
|
|
460
|
+
const form = new Form({
|
|
461
|
+
...config,
|
|
462
|
+
defaults: config.defaults || {},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
form.setValues(req.body || {});
|
|
466
|
+
|
|
467
|
+
const result = await form.submit();
|
|
468
|
+
req.form = form;
|
|
469
|
+
req.formResult = result;
|
|
470
|
+
|
|
471
|
+
if (!result.success) {
|
|
472
|
+
res.json({ error: 'Validation failed', errors: result.errors }, 422);
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ===== INTERNAL HELPERS =====
|
|
479
|
+
|
|
480
|
+
_notifyWatchers(field, newValue, oldValue) {
|
|
481
|
+
if (this._watchers[field]) {
|
|
482
|
+
for (const fn of this._watchers[field]) fn(newValue, oldValue);
|
|
483
|
+
}
|
|
484
|
+
for (const fn of this._globalWatchers) fn(field, newValue, oldValue);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
static _getPath(obj, path) {
|
|
488
|
+
return path.split('.').reduce((curr, key) => {
|
|
489
|
+
if (curr === undefined || curr === null) return undefined;
|
|
490
|
+
// Handle array notation: "items.0.name"
|
|
491
|
+
const idx = parseInt(key);
|
|
492
|
+
if (!isNaN(idx) && Array.isArray(curr)) return curr[idx];
|
|
493
|
+
return curr[key];
|
|
494
|
+
}, obj);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
static _setPath(obj, path, value) {
|
|
498
|
+
const keys = path.split('.');
|
|
499
|
+
let curr = obj;
|
|
500
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
501
|
+
const key = keys[i];
|
|
502
|
+
const nextKey = keys[i + 1];
|
|
503
|
+
const idx = parseInt(key);
|
|
504
|
+
|
|
505
|
+
if (!isNaN(idx) && Array.isArray(curr)) {
|
|
506
|
+
if (curr[idx] === undefined) curr[idx] = isNaN(parseInt(nextKey)) ? {} : [];
|
|
507
|
+
curr = curr[idx];
|
|
508
|
+
} else {
|
|
509
|
+
if (curr[key] === undefined) curr[key] = isNaN(parseInt(nextKey)) ? {} : [];
|
|
510
|
+
curr = curr[key];
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const lastKey = keys[keys.length - 1];
|
|
515
|
+
const lastIdx = parseInt(lastKey);
|
|
516
|
+
if (!isNaN(lastIdx) && Array.isArray(curr)) {
|
|
517
|
+
curr[lastIdx] = value;
|
|
518
|
+
} else {
|
|
519
|
+
curr[lastKey] = value;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
static _flattenKeys(obj, prefix = '') {
|
|
524
|
+
const keys = [];
|
|
525
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
526
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
527
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
528
|
+
keys.push(...Form._flattenKeys(value, fullKey));
|
|
529
|
+
} else {
|
|
530
|
+
keys.push(fullKey);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return keys;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
static _isEqual(a, b) {
|
|
537
|
+
if (a === b) return true;
|
|
538
|
+
if (a === null || b === null) return false;
|
|
539
|
+
if (typeof a !== typeof b) return false;
|
|
540
|
+
if (typeof a === 'object') {
|
|
541
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
module.exports = { Form };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VoltJS Hash
|
|
3
|
+
*
|
|
4
|
+
* Common hashing utilities wrapping Node crypto.
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* const { Hash } = require('voltjs');
|
|
8
|
+
*
|
|
9
|
+
* const hashed = await Hash.make('password123');
|
|
10
|
+
* const valid = await Hash.verify('password123', hashed);
|
|
11
|
+
*
|
|
12
|
+
* const sha = Hash.sha256('data');
|
|
13
|
+
* const md5 = Hash.md5('data');
|
|
14
|
+
* const token = Hash.random(32);
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
|
|
21
|
+
class Hash {
|
|
22
|
+
/** Hash a password using PBKDF2 */
|
|
23
|
+
static async make(password, options = {}) {
|
|
24
|
+
const salt = options.salt || crypto.randomBytes(32).toString('hex');
|
|
25
|
+
const iterations = options.iterations || 100000;
|
|
26
|
+
const keyLen = options.keyLen || 64;
|
|
27
|
+
const digest = options.digest || 'sha512';
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
crypto.pbkdf2(password, salt, iterations, keyLen, digest, (err, key) => {
|
|
31
|
+
if (err) return reject(err);
|
|
32
|
+
resolve(`${digest}:${iterations}:${salt}:${key.toString('hex')}`);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Verify a password against a hash */
|
|
38
|
+
static async verify(password, hash) {
|
|
39
|
+
const [digest, iterations, salt, key] = hash.split(':');
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
crypto.pbkdf2(password, salt, parseInt(iterations), Buffer.from(key, 'hex').length, digest, (err, derivedKey) => {
|
|
43
|
+
if (err) return reject(err);
|
|
44
|
+
resolve(crypto.timingSafeEqual(Buffer.from(key, 'hex'), derivedKey));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** SHA-256 hash */
|
|
50
|
+
static sha256(data) {
|
|
51
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** SHA-512 hash */
|
|
55
|
+
static sha512(data) {
|
|
56
|
+
return crypto.createHash('sha512').update(data).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** MD5 hash (not secure, for checksums only) */
|
|
60
|
+
static md5(data) {
|
|
61
|
+
return crypto.createHash('md5').update(data).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** HMAC-SHA256 */
|
|
65
|
+
static hmac(data, secret, algorithm = 'sha256') {
|
|
66
|
+
return crypto.createHmac(algorithm, secret).update(data).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Generate random hex string */
|
|
70
|
+
static random(length = 32) {
|
|
71
|
+
return crypto.randomBytes(length).toString('hex');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Generate random base64 string */
|
|
75
|
+
static randomBase64(length = 32) {
|
|
76
|
+
return crypto.randomBytes(length).toString('base64url');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Generate random integer in range */
|
|
80
|
+
static randomInt(min, max) {
|
|
81
|
+
return crypto.randomInt(min, max + 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Generate UUID v4 */
|
|
85
|
+
static uuid() {
|
|
86
|
+
return crypto.randomUUID();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** CRC32 checksum */
|
|
90
|
+
static crc32(data) {
|
|
91
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
92
|
+
let crc = 0xFFFFFFFF;
|
|
93
|
+
for (let i = 0; i < buf.length; i++) {
|
|
94
|
+
crc ^= buf[i];
|
|
95
|
+
for (let j = 0; j < 8; j++) {
|
|
96
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xEDB88320 : 0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, '0');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Base64 encode */
|
|
103
|
+
static base64Encode(data) {
|
|
104
|
+
return Buffer.from(data).toString('base64');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Base64 decode */
|
|
108
|
+
static base64Decode(data) {
|
|
109
|
+
return Buffer.from(data, 'base64').toString('utf-8');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Timing-safe comparison */
|
|
113
|
+
static equals(a, b) {
|
|
114
|
+
const bufA = Buffer.from(a);
|
|
115
|
+
const bufB = Buffer.from(b);
|
|
116
|
+
if (bufA.length !== bufB.length) return false;
|
|
117
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { Hash };
|