odac 1.2.0 → 1.4.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/.agent/rules/memory.md +16 -1
- package/.github/workflows/release.yml +27 -5
- package/.husky/pre-push +3 -3
- package/.releaserc.js +2 -2
- package/AGENTS.md +47 -0
- package/CHANGELOG.md +64 -0
- package/README.md +1 -1
- package/bin/odac.js +187 -8
- package/client/odac.js +243 -178
- package/docs/ai/README.md +49 -0
- package/docs/ai/skills/SKILL.md +39 -0
- package/docs/ai/skills/backend/authentication.md +67 -0
- package/docs/ai/skills/backend/config.md +32 -0
- package/docs/ai/skills/backend/controllers.md +62 -0
- package/docs/ai/skills/backend/cron.md +50 -0
- package/docs/ai/skills/backend/database.md +21 -0
- package/docs/ai/skills/backend/forms.md +19 -0
- package/docs/ai/skills/backend/ipc.md +55 -0
- package/docs/ai/skills/backend/mail.md +34 -0
- package/docs/ai/skills/backend/request_response.md +35 -0
- package/docs/ai/skills/backend/routing.md +51 -0
- package/docs/ai/skills/backend/storage.md +43 -0
- package/docs/ai/skills/backend/streaming.md +34 -0
- package/docs/ai/skills/backend/structure.md +57 -0
- package/docs/ai/skills/backend/translations.md +42 -0
- package/docs/ai/skills/backend/utilities.md +24 -0
- package/docs/ai/skills/backend/validation.md +53 -0
- package/docs/ai/skills/backend/views.md +61 -0
- package/docs/ai/skills/frontend/core.md +66 -0
- package/docs/ai/skills/frontend/forms.md +21 -0
- package/docs/ai/skills/frontend/navigation.md +20 -0
- package/docs/ai/skills/frontend/realtime.md +47 -0
- package/docs/backend/04-routing/09-websocket.md +14 -1
- package/docs/backend/10-authentication/01-user-logins-with-authjs.md +2 -0
- package/docs/backend/10-authentication/05-session-management.md +25 -3
- package/package.json +13 -13
- package/src/Auth.js +100 -15
- package/src/Database.js +1 -1
- package/src/Mail.js +19 -9
- package/src/Odac.js +17 -14
- package/src/Request.js +5 -1
- package/src/Route/Internal.js +21 -18
- package/src/Route/MimeTypes.js +56 -0
- package/src/Route.js +136 -92
- package/src/Validator.js +23 -14
- package/src/View/Form.js +91 -51
- package/src/View.js +15 -10
- package/src/WebSocket.js +45 -12
- package/test/Auth.test.js +249 -0
- package/test/Client.test.js +29 -0
- package/test/Odac.test.js +4 -2
- package/test/Route.test.js +104 -0
- package/test/View/Form.test.js +37 -0
- package/test/WebSocket.test.js +141 -3
- package/.github/workflows/test-publish.yml +0 -36
package/src/View/Form.js
CHANGED
|
@@ -3,6 +3,18 @@ const nodeCrypto = require('crypto')
|
|
|
3
3
|
class Form {
|
|
4
4
|
static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
|
|
5
5
|
|
|
6
|
+
static escapeHtml(value) {
|
|
7
|
+
if (value === null || value === undefined) return ''
|
|
8
|
+
const map = {
|
|
9
|
+
'&': '&',
|
|
10
|
+
'<': '<',
|
|
11
|
+
'>': '>',
|
|
12
|
+
'"': '"',
|
|
13
|
+
"'": '''
|
|
14
|
+
}
|
|
15
|
+
return String(value).replace(/[&<>"']/g, ch => map[ch])
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
static parse(content, Odac) {
|
|
7
19
|
for (const type of this.FORM_TYPES) {
|
|
8
20
|
content = this.parseFormType(content, Odac, type)
|
|
@@ -14,8 +26,14 @@ class Form {
|
|
|
14
26
|
const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
|
|
15
27
|
return content.replace(regex, match => {
|
|
16
28
|
const formConfig = this.extractConfig(match, null, type)
|
|
17
|
-
|
|
18
|
-
|
|
29
|
+
let configStr = JSON.stringify(formConfig)
|
|
30
|
+
let matchStr = JSON.stringify(match)
|
|
31
|
+
|
|
32
|
+
// Unquote dynamic variables to make them live JS expressions in the compiled view
|
|
33
|
+
// We avoid {{ }} here because View engine would turn them into ${ } which is invalid in naked JS
|
|
34
|
+
configStr = configStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await $1)')
|
|
35
|
+
matchStr = matchStr.replace(/\{\{([\s\S]*?)\}\}/g, '" + (await Odac.Var(await $1).html()) + "')
|
|
36
|
+
|
|
19
37
|
return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
|
|
20
38
|
})
|
|
21
39
|
}
|
|
@@ -91,7 +109,7 @@ class Form {
|
|
|
91
109
|
if (redirectMatch) config.redirect = redirectMatch[1]
|
|
92
110
|
if (autologinMatch) config.autologin = autologinMatch[1] !== 'false'
|
|
93
111
|
|
|
94
|
-
const submitMatch = html.match(/<odac:submit([
|
|
112
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
95
113
|
if (submitMatch) {
|
|
96
114
|
const submitTag = submitMatch[1]
|
|
97
115
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -145,22 +163,25 @@ class Form {
|
|
|
145
163
|
id: null,
|
|
146
164
|
unique: false,
|
|
147
165
|
skip: false,
|
|
166
|
+
value: null,
|
|
148
167
|
validations: []
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
const typeMatch = fieldTag.match(/type=
|
|
152
|
-
const placeholderMatch = fieldTag.match(/placeholder=
|
|
153
|
-
const labelMatch = fieldTag.match(/label=
|
|
154
|
-
const classMatch = fieldTag.match(/class=
|
|
155
|
-
const idMatch = fieldTag.match(/id=
|
|
170
|
+
const typeMatch = fieldTag.match(/type=(["'])(.*?)\1/)
|
|
171
|
+
const placeholderMatch = fieldTag.match(/placeholder=(["'])(.*?)\1/)
|
|
172
|
+
const labelMatch = fieldTag.match(/label=(["'])(.*?)\1/)
|
|
173
|
+
const classMatch = fieldTag.match(/class=(["'])(.*?)\1/)
|
|
174
|
+
const idMatch = fieldTag.match(/id=(["'])(.*?)\1/)
|
|
175
|
+
const valueMatch = fieldTag.match(/value=(["'])(.*?)\1/)
|
|
156
176
|
const uniqueMatch = fieldTag.match(/unique=["']([^"']+)["']/) || fieldTag.match(/\sunique[\s/>]/)
|
|
157
177
|
const skipMatch = fieldTag.match(/skip=["']([^"']+)["']/) || fieldTag.match(/\sskip[\s/>]/)
|
|
158
178
|
|
|
159
|
-
if (typeMatch) field.type = typeMatch[
|
|
160
|
-
if (placeholderMatch) field.placeholder = placeholderMatch[
|
|
161
|
-
if (labelMatch) field.label = labelMatch[
|
|
162
|
-
if (classMatch) field.class = classMatch[
|
|
163
|
-
if (idMatch) field.id = idMatch[
|
|
179
|
+
if (typeMatch) field.type = typeMatch[2]
|
|
180
|
+
if (placeholderMatch) field.placeholder = placeholderMatch[2]
|
|
181
|
+
if (labelMatch) field.label = labelMatch[2]
|
|
182
|
+
if (classMatch) field.class = classMatch[2]
|
|
183
|
+
if (idMatch) field.id = idMatch[2]
|
|
184
|
+
if (valueMatch) field.value = valueMatch[2]
|
|
164
185
|
if (uniqueMatch) field.unique = uniqueMatch[1] !== 'false'
|
|
165
186
|
if (skipMatch) field.skip = skipMatch[1] !== 'false'
|
|
166
187
|
|
|
@@ -181,7 +202,7 @@ class Form {
|
|
|
181
202
|
|
|
182
203
|
// Capture generic attributes
|
|
183
204
|
const extraAttrs = {}
|
|
184
|
-
const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
|
|
205
|
+
const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip', 'value']
|
|
185
206
|
const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
|
|
186
207
|
let attrMatch
|
|
187
208
|
// Clean tag to just attributes part for safer regex matching if needed,
|
|
@@ -203,11 +224,11 @@ class Form {
|
|
|
203
224
|
}
|
|
204
225
|
|
|
205
226
|
static parseSet(html) {
|
|
206
|
-
const nameMatch = html.match(/name=
|
|
227
|
+
const nameMatch = html.match(/name=(["'])(.*?)\1/)
|
|
207
228
|
if (!nameMatch) return null
|
|
208
229
|
|
|
209
230
|
const set = {
|
|
210
|
-
name: nameMatch[
|
|
231
|
+
name: nameMatch[2],
|
|
211
232
|
value: null,
|
|
212
233
|
compute: null,
|
|
213
234
|
callback: null,
|
|
@@ -215,14 +236,14 @@ class Form {
|
|
|
215
236
|
}
|
|
216
237
|
|
|
217
238
|
const valueMatch = html.match(/value=(["'])(.*?)\1/)
|
|
218
|
-
const computeMatch = html.match(/compute=
|
|
219
|
-
const callbackMatch = html.match(/callback=
|
|
220
|
-
const ifEmptyMatch = html.match(/if-empty=
|
|
239
|
+
const computeMatch = html.match(/compute=(["'])(.*?)\1/)
|
|
240
|
+
const callbackMatch = html.match(/callback=(["'])(.*?)\1/)
|
|
241
|
+
const ifEmptyMatch = html.match(/if-empty=(["'])(.*?)\1/) || html.match(/\sif-empty[\s/>]/)
|
|
221
242
|
|
|
222
243
|
if (valueMatch) set.value = valueMatch[2]
|
|
223
|
-
if (computeMatch) set.compute = computeMatch[
|
|
224
|
-
if (callbackMatch) set.callback = callbackMatch[
|
|
225
|
-
if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[
|
|
244
|
+
if (computeMatch) set.compute = computeMatch[2]
|
|
245
|
+
if (callbackMatch) set.callback = callbackMatch[2]
|
|
246
|
+
if (ifEmptyMatch) set.ifEmpty = ifEmptyMatch[2] !== 'false'
|
|
226
247
|
|
|
227
248
|
return set
|
|
228
249
|
}
|
|
@@ -253,6 +274,9 @@ class Form {
|
|
|
253
274
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
254
275
|
const field = this.parseInput(fieldMatch)
|
|
255
276
|
if (!field) return fieldMatch
|
|
277
|
+
// Sync with resolved config value if available
|
|
278
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
279
|
+
if (configField) field.value = configField.value
|
|
256
280
|
return this.generateFieldHtml(field)
|
|
257
281
|
})
|
|
258
282
|
|
|
@@ -279,31 +303,38 @@ class Form {
|
|
|
279
303
|
|
|
280
304
|
static generateFieldHtml(field) {
|
|
281
305
|
let html = ''
|
|
306
|
+
const escapedName = this.escapeHtml(field.name)
|
|
307
|
+
const escapedType = this.escapeHtml(field.type)
|
|
308
|
+
const escapedPlaceholder = this.escapeHtml(field.placeholder)
|
|
282
309
|
|
|
283
310
|
if (field.label && field.type !== 'checkbox') {
|
|
284
|
-
const fieldId = field.id || `odac-${field.name}`
|
|
285
|
-
html += `<label for="${fieldId}">${field.label}</label>\n`
|
|
311
|
+
const fieldId = this.escapeHtml(field.id || `odac-${field.name}`)
|
|
312
|
+
html += `<label for="${fieldId}">${this.escapeHtml(field.label)}</label>\n`
|
|
286
313
|
}
|
|
287
314
|
|
|
288
|
-
const classAttr = field.class ? ` class="${field.class}"` : ''
|
|
289
|
-
const idAttr = field.id ? ` id="${field.id}"` : ` id="odac-${field.name}"`
|
|
315
|
+
const classAttr = field.class ? ` class="${this.escapeHtml(field.class)}"` : ''
|
|
316
|
+
const idAttr = field.id ? ` id="${this.escapeHtml(field.id)}"` : ` id="${this.escapeHtml(`odac-${field.name}`)}"`
|
|
317
|
+
const valueAttr = field.value !== null ? ` value="${this.escapeHtml(field.value)}"` : ''
|
|
290
318
|
|
|
291
319
|
if (field.type === 'checkbox') {
|
|
292
320
|
const attrs = this.buildHtml5Attributes(field)
|
|
321
|
+
const checkedAttr = field.value === '1' || field.value === true || field.value === 'true' ? ' checked' : ''
|
|
293
322
|
if (field.label) {
|
|
294
323
|
html += `<label>\n`
|
|
295
|
-
html += ` <input type="checkbox"${idAttr} name="${
|
|
296
|
-
html += ` ${field.label}\n`
|
|
324
|
+
html += ` <input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
|
|
325
|
+
html += ` ${this.escapeHtml(field.label)}\n`
|
|
297
326
|
html += `</label>\n`
|
|
298
327
|
} else {
|
|
299
|
-
html += `<input type="checkbox"${idAttr} name="${
|
|
328
|
+
html += `<input type="checkbox"${idAttr} name="${escapedName}" value="1"${classAttr}${checkedAttr}${attrs}>\n`
|
|
300
329
|
}
|
|
301
330
|
} else if (field.type === 'textarea') {
|
|
302
331
|
const attrs = this.buildHtml5Attributes(field)
|
|
303
|
-
html += `<textarea${idAttr} name="${
|
|
332
|
+
html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtml(
|
|
333
|
+
field.value || ''
|
|
334
|
+
)}</textarea>\n`
|
|
304
335
|
} else {
|
|
305
336
|
const attrs = this.buildHtml5Attributes(field)
|
|
306
|
-
html += `<input type="${
|
|
337
|
+
html += `<input type="${escapedType}"${idAttr} name="${escapedName}"${valueAttr} placeholder="${escapedPlaceholder}"${classAttr}${attrs}>\n`
|
|
307
338
|
}
|
|
308
339
|
|
|
309
340
|
return html
|
|
@@ -319,7 +350,7 @@ class Form {
|
|
|
319
350
|
if (val === '') {
|
|
320
351
|
attrs += ` ${key}`
|
|
321
352
|
} else {
|
|
322
|
-
attrs += ` ${key}="${
|
|
353
|
+
attrs += ` ${key}="${this.escapeHtml(val)}"`
|
|
323
354
|
}
|
|
324
355
|
}
|
|
325
356
|
}
|
|
@@ -406,11 +437,11 @@ class Form {
|
|
|
406
437
|
if (html5Rules.max) attrs += ` max="${html5Rules.max}"`
|
|
407
438
|
if (html5Rules.pattern) attrs += ` pattern="${html5Rules.pattern}"`
|
|
408
439
|
|
|
409
|
-
if (errorMessages.required) attrs += ` data-error-required="${errorMessages.required
|
|
410
|
-
if (errorMessages.minlength) attrs += ` data-error-minlength="${errorMessages.minlength
|
|
411
|
-
if (errorMessages.maxlength) attrs += ` data-error-maxlength="${errorMessages.maxlength
|
|
412
|
-
if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern
|
|
413
|
-
if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email
|
|
440
|
+
if (errorMessages.required) attrs += ` data-error-required="${this.escapeHtml(errorMessages.required)}"`
|
|
441
|
+
if (errorMessages.minlength) attrs += ` data-error-minlength="${this.escapeHtml(errorMessages.minlength)}"`
|
|
442
|
+
if (errorMessages.maxlength) attrs += ` data-error-maxlength="${this.escapeHtml(errorMessages.maxlength)}"`
|
|
443
|
+
if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
|
|
444
|
+
if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
|
|
414
445
|
|
|
415
446
|
attrs = this.appendExtraAttributes(attrs, field)
|
|
416
447
|
|
|
@@ -434,7 +465,7 @@ class Form {
|
|
|
434
465
|
|
|
435
466
|
if (redirectMatch) config.redirect = redirectMatch[1]
|
|
436
467
|
|
|
437
|
-
const submitMatch = html.match(/<odac:submit([
|
|
468
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
438
469
|
if (submitMatch) {
|
|
439
470
|
const submitTag = submitMatch[1]
|
|
440
471
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -489,6 +520,9 @@ class Form {
|
|
|
489
520
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
490
521
|
const field = this.parseInput(fieldMatch)
|
|
491
522
|
if (!field) return fieldMatch
|
|
523
|
+
// Sync with resolved config value if available
|
|
524
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
525
|
+
if (configField) field.value = configField.value
|
|
492
526
|
return this.generateFieldHtml(field)
|
|
493
527
|
})
|
|
494
528
|
|
|
@@ -551,8 +585,10 @@ class Form {
|
|
|
551
585
|
if (tableMatch) config.table = tableMatch
|
|
552
586
|
if (redirectMatch) config.redirect = redirectMatch
|
|
553
587
|
if (successMatch) config.successMessage = successMatch
|
|
588
|
+
const clearMatch = extractAttr('clear')
|
|
589
|
+
if (clearMatch !== null) config.clear = clearMatch === 'true' || clearMatch === ''
|
|
554
590
|
|
|
555
|
-
const submitMatch = html.match(/<odac:submit([
|
|
591
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
556
592
|
if (submitMatch) {
|
|
557
593
|
const submitTag = submitMatch[1]
|
|
558
594
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -618,29 +654,30 @@ class Form {
|
|
|
618
654
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
619
655
|
const field = this.parseInput(fieldMatch)
|
|
620
656
|
if (!field) return fieldMatch
|
|
657
|
+
// Sync with resolved config value if available
|
|
658
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
659
|
+
if (configField) field.value = configField.value
|
|
621
660
|
return this.generateFieldHtml(field)
|
|
622
661
|
})
|
|
623
662
|
|
|
624
|
-
const escapeHtml = str =>
|
|
625
|
-
String(str).replace(/[&<>"']/g, m => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''})[m])
|
|
626
|
-
|
|
627
663
|
const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
|
|
628
664
|
if (submitMatch) {
|
|
629
|
-
let submitAttrs = `type="submit" data-submit-text="${escapeHtml(submitText)}" data-loading-text="${escapeHtml(submitLoading)}"`
|
|
630
|
-
if (config.submitClass) submitAttrs += ` class="${escapeHtml(config.submitClass)}"`
|
|
631
|
-
if (config.submitStyle) submitAttrs += ` style="${escapeHtml(config.submitStyle)}"`
|
|
632
|
-
if (config.submitId) submitAttrs += ` id="${escapeHtml(config.submitId)}"`
|
|
633
|
-
const submitButton = `<button ${submitAttrs}>${escapeHtml(submitText)}</button>`
|
|
665
|
+
let submitAttrs = `type="submit" data-submit-text="${this.escapeHtml(submitText)}" data-loading-text="${this.escapeHtml(submitLoading)}"`
|
|
666
|
+
if (config.submitClass) submitAttrs += ` class="${this.escapeHtml(config.submitClass)}"`
|
|
667
|
+
if (config.submitStyle) submitAttrs += ` style="${this.escapeHtml(config.submitStyle)}"`
|
|
668
|
+
if (config.submitId) submitAttrs += ` id="${this.escapeHtml(config.submitId)}"`
|
|
669
|
+
const submitButton = `<button ${submitAttrs}>${this.escapeHtml(submitText)}</button>`
|
|
634
670
|
innerContent = innerContent.replace(submitMatch[0], submitButton)
|
|
635
671
|
}
|
|
636
672
|
|
|
637
673
|
innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
|
|
638
674
|
|
|
639
|
-
let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
|
|
640
|
-
if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
|
|
675
|
+
let formAttrs = `class="odac-custom-form${config.class ? ' ' + this.escapeHtml(config.class) : ''}" data-odac-form="${this.escapeHtml(formToken)}" method="${this.escapeHtml(method)}" action="${this.escapeHtml(formAction)}" novalidate`
|
|
676
|
+
if (config.id) formAttrs += ` id="${this.escapeHtml(config.id)}"`
|
|
677
|
+
if (config.clear !== undefined) formAttrs += ` clear="${config.clear}"`
|
|
641
678
|
|
|
642
679
|
let html = `<form ${formAttrs}>\n`
|
|
643
|
-
html += ` <input type="hidden" name="_odac_form_token" value="${escapeHtml(formToken)}">\n`
|
|
680
|
+
html += ` <input type="hidden" name="_odac_form_token" value="${this.escapeHtml(formToken)}">\n`
|
|
644
681
|
html += innerContent
|
|
645
682
|
html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
|
|
646
683
|
html += `</form>`
|
|
@@ -693,7 +730,7 @@ class Form {
|
|
|
693
730
|
})
|
|
694
731
|
}
|
|
695
732
|
|
|
696
|
-
const submitMatch = html.match(/<odac:submit([
|
|
733
|
+
const submitMatch = html.match(/<odac:submit([^>]*?)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
697
734
|
if (submitMatch) {
|
|
698
735
|
const submitTag = submitMatch[1]
|
|
699
736
|
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
@@ -751,6 +788,9 @@ class Form {
|
|
|
751
788
|
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
752
789
|
const field = this.parseInput(fieldMatch)
|
|
753
790
|
if (!field) return fieldMatch
|
|
791
|
+
// Sync with resolved config value if available
|
|
792
|
+
const configField = config.fields.find(f => f.name === field.name)
|
|
793
|
+
if (configField) field.value = configField.value
|
|
754
794
|
return this.generateFieldHtml(field)
|
|
755
795
|
})
|
|
756
796
|
}
|
package/src/View.js
CHANGED
|
@@ -58,6 +58,7 @@ class View {
|
|
|
58
58
|
function: '{ let _arr = $constructor; for(let $key in _arr){ let $value = _arr[$key];',
|
|
59
59
|
end: '}}',
|
|
60
60
|
arguments: {
|
|
61
|
+
in: null,
|
|
61
62
|
var: null,
|
|
62
63
|
get: null,
|
|
63
64
|
key: 'key',
|
|
@@ -83,6 +84,7 @@ class View {
|
|
|
83
84
|
},
|
|
84
85
|
list: {
|
|
85
86
|
arguments: {
|
|
87
|
+
in: null,
|
|
86
88
|
var: null,
|
|
87
89
|
get: null,
|
|
88
90
|
key: 'key',
|
|
@@ -149,7 +151,7 @@ class View {
|
|
|
149
151
|
if (this.#part[element]) {
|
|
150
152
|
let viewPath = this.#part[element]
|
|
151
153
|
if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
|
|
152
|
-
if (
|
|
154
|
+
if (await this.#exists(`./view/${element}/${viewPath}.html`)) {
|
|
153
155
|
const html = await this.#render(`./view/${element}/${viewPath}.html`)
|
|
154
156
|
output[element] = html
|
|
155
157
|
|
|
@@ -169,7 +171,7 @@ class View {
|
|
|
169
171
|
if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
|
|
170
172
|
let viewPath = this.#part[key]
|
|
171
173
|
if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
|
|
172
|
-
if (
|
|
174
|
+
if (await this.#exists(`./view/${key}/${viewPath}.html`)) {
|
|
173
175
|
try {
|
|
174
176
|
const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
|
|
175
177
|
const titleMatch = partHtml.match(TITLE_REGEX)
|
|
@@ -217,7 +219,7 @@ class View {
|
|
|
217
219
|
if (['all', 'skeleton'].includes(key)) continue
|
|
218
220
|
if (!this.#part[key]) continue
|
|
219
221
|
if (this.#part[key].includes('.')) this.#part[key] = this.#part[key].replace(/\./g, '/')
|
|
220
|
-
if (
|
|
222
|
+
if (await this.#exists(`./view/${key}/${this.#part[key]}.html`)) {
|
|
221
223
|
result = result.replace(`{{ ${key.toUpperCase()} }}`, await this.#render(`./view/${key}/${this.#part[key]}.html`))
|
|
222
224
|
}
|
|
223
225
|
}
|
|
@@ -229,7 +231,7 @@ class View {
|
|
|
229
231
|
let file = this.#part.all.split('.')
|
|
230
232
|
file.splice(-1, 0, part.toLowerCase())
|
|
231
233
|
file = file.join('/')
|
|
232
|
-
if (
|
|
234
|
+
if (await this.#exists(`./view/${file}.html`)) {
|
|
233
235
|
result = result.replace(`{{ ${part.toUpperCase()} }}`, await this.#render(`./view/${file}.html`))
|
|
234
236
|
}
|
|
235
237
|
}
|
|
@@ -381,7 +383,7 @@ class View {
|
|
|
381
383
|
}
|
|
382
384
|
}
|
|
383
385
|
|
|
384
|
-
let mtime
|
|
386
|
+
let mtime
|
|
385
387
|
let content = null
|
|
386
388
|
|
|
387
389
|
try {
|
|
@@ -453,12 +455,15 @@ class View {
|
|
|
453
455
|
let fun = func.function
|
|
454
456
|
|
|
455
457
|
if (key === 'for' || key === 'list') {
|
|
456
|
-
if (!vars.var && !vars.get) {
|
|
457
|
-
console.error(`"var" or "
|
|
458
|
+
if (!vars.var && !vars.get && !vars.in) {
|
|
459
|
+
console.error(`"var", "get" or "in" is required for "${match}"\n in "${file}"`)
|
|
458
460
|
continue
|
|
459
461
|
}
|
|
460
462
|
let constructor
|
|
461
|
-
if (vars.
|
|
463
|
+
if (vars.in) {
|
|
464
|
+
constructor = `await ${vars.in}`
|
|
465
|
+
delete vars.in
|
|
466
|
+
} else if (vars.var) {
|
|
462
467
|
constructor = `await ${vars.var}`
|
|
463
468
|
delete vars.var
|
|
464
469
|
} else if (vars.get) {
|
|
@@ -485,8 +490,8 @@ class View {
|
|
|
485
490
|
}
|
|
486
491
|
}
|
|
487
492
|
let cache = `${nodeCrypto.createHash('md5').update(file).digest('hex')}`
|
|
488
|
-
|
|
489
|
-
|
|
493
|
+
await fsPromises.mkdir(CACHE_DIR, {recursive: true})
|
|
494
|
+
await fsPromises.writeFile(
|
|
490
495
|
`${CACHE_DIR}/${cache}`,
|
|
491
496
|
`module.exports = async (Odac, get, __) => {\nlet html = '';\n${result}\nreturn html.trim()\n}`
|
|
492
497
|
)
|
package/src/WebSocket.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
|
|
3
3
|
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
4
|
-
const
|
|
4
|
+
const DEFAULT_MAX_PAYLOAD = 10 * 1024 * 1024
|
|
5
|
+
const DEFAULT_RATE_LIMIT_MAX = 50
|
|
6
|
+
const DEFAULT_RATE_LIMIT_WINDOW = 1000
|
|
7
|
+
|
|
5
8
|
const OPCODE = {
|
|
6
9
|
CONTINUATION: 0x0,
|
|
7
10
|
TEXT: 0x1,
|
|
@@ -18,13 +21,29 @@ class WebSocketClient {
|
|
|
18
21
|
#server
|
|
19
22
|
#id
|
|
20
23
|
#rooms = new Set()
|
|
24
|
+
#maxPayload
|
|
25
|
+
#rateLimitMax
|
|
26
|
+
#rateLimitWindow
|
|
27
|
+
#messageCount = 0
|
|
28
|
+
#rateLimitTimer
|
|
21
29
|
data = {}
|
|
22
30
|
|
|
23
|
-
constructor(socket, server, id) {
|
|
31
|
+
constructor(socket, server, id, options = {}) {
|
|
24
32
|
this.#socket = socket
|
|
25
33
|
this.#socket.pause()
|
|
26
34
|
this.#server = server
|
|
27
35
|
this.#id = id
|
|
36
|
+
this.#maxPayload = options.maxPayload || DEFAULT_MAX_PAYLOAD
|
|
37
|
+
|
|
38
|
+
this.#rateLimitMax = options.rateLimit?.max ?? DEFAULT_RATE_LIMIT_MAX
|
|
39
|
+
this.#rateLimitWindow = options.rateLimit?.window ?? DEFAULT_RATE_LIMIT_WINDOW
|
|
40
|
+
|
|
41
|
+
if (this.#rateLimitMax > 0) {
|
|
42
|
+
this.#rateLimitTimer = setInterval(() => {
|
|
43
|
+
this.#messageCount = 0
|
|
44
|
+
}, this.#rateLimitWindow)
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
this.#setupListeners()
|
|
29
48
|
}
|
|
30
49
|
|
|
@@ -93,7 +112,7 @@ class WebSocketClient {
|
|
|
93
112
|
offset = 10
|
|
94
113
|
}
|
|
95
114
|
|
|
96
|
-
if (payloadLength >
|
|
115
|
+
if (payloadLength > this.#maxPayload) {
|
|
97
116
|
this.close(1009, 'Payload too large')
|
|
98
117
|
return null
|
|
99
118
|
}
|
|
@@ -125,6 +144,14 @@ class WebSocketClient {
|
|
|
125
144
|
}
|
|
126
145
|
|
|
127
146
|
#handleFrame(frame) {
|
|
147
|
+
if (this.#rateLimitMax > 0) {
|
|
148
|
+
this.#messageCount++
|
|
149
|
+
if (this.#messageCount > this.#rateLimitMax) {
|
|
150
|
+
this.close(1008, 'Rate limit exceeded')
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
switch (frame.opcode) {
|
|
129
156
|
case OPCODE.TEXT:
|
|
130
157
|
this.#handleMessage(frame.payload.toString('utf8'))
|
|
@@ -158,6 +185,8 @@ class WebSocketClient {
|
|
|
158
185
|
if (this.#closed) return
|
|
159
186
|
this.#closed = true
|
|
160
187
|
|
|
188
|
+
if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
|
|
189
|
+
|
|
161
190
|
this.#socket.removeAllListeners()
|
|
162
191
|
|
|
163
192
|
for (const room of this.#rooms) {
|
|
@@ -169,8 +198,7 @@ class WebSocketClient {
|
|
|
169
198
|
}
|
|
170
199
|
|
|
171
200
|
#sendFrame(opcode, data) {
|
|
172
|
-
if (this.#closed) return
|
|
173
|
-
|
|
201
|
+
if (this.#closed && opcode !== OPCODE.CLOSE) return
|
|
174
202
|
const payload = Buffer.isBuffer(data) ? data : Buffer.from(data)
|
|
175
203
|
const length = payload.length
|
|
176
204
|
|
|
@@ -240,6 +268,8 @@ class WebSocketClient {
|
|
|
240
268
|
if (this.#closed) return
|
|
241
269
|
this.#closed = true
|
|
242
270
|
|
|
271
|
+
if (this.#rateLimitTimer) clearInterval(this.#rateLimitTimer)
|
|
272
|
+
|
|
243
273
|
const reasonBuffer = Buffer.from(reason)
|
|
244
274
|
const payload = Buffer.alloc(2 + reasonBuffer.length)
|
|
245
275
|
payload.writeUInt16BE(code, 0)
|
|
@@ -287,14 +317,14 @@ class WebSocketServer {
|
|
|
287
317
|
#rooms = new Map()
|
|
288
318
|
#routes = new Map()
|
|
289
319
|
|
|
290
|
-
route(path, handler) {
|
|
291
|
-
this.#routes.set(path, handler)
|
|
320
|
+
route(path, handler, options = {}) {
|
|
321
|
+
this.#routes.set(path, {handler, options})
|
|
292
322
|
}
|
|
293
323
|
|
|
294
324
|
getRoute(path) {
|
|
295
325
|
if (this.#routes.has(path)) return this.#routes.get(path)
|
|
296
326
|
|
|
297
|
-
for (const [pattern,
|
|
327
|
+
for (const [pattern, config] of this.#routes) {
|
|
298
328
|
if (!pattern.includes('{')) continue
|
|
299
329
|
const regex = new RegExp('^' + pattern.replace(/\{[^}]+\}/g, '([^/]+)') + '$')
|
|
300
330
|
const match = path.match(regex)
|
|
@@ -304,7 +334,11 @@ class WebSocketServer {
|
|
|
304
334
|
paramNames.forEach((name, i) => {
|
|
305
335
|
params[name.slice(1, -1)] = match[i + 1]
|
|
306
336
|
})
|
|
307
|
-
return {
|
|
337
|
+
return {
|
|
338
|
+
handler: config.handler,
|
|
339
|
+
options: config.options,
|
|
340
|
+
params
|
|
341
|
+
}
|
|
308
342
|
}
|
|
309
343
|
}
|
|
310
344
|
return null
|
|
@@ -320,8 +354,7 @@ class WebSocketServer {
|
|
|
320
354
|
return
|
|
321
355
|
}
|
|
322
356
|
|
|
323
|
-
const handler =
|
|
324
|
-
const params = routeInfo.params || {}
|
|
357
|
+
const {handler, params = {}, options = {}} = routeInfo
|
|
325
358
|
|
|
326
359
|
const key = req.headers['sec-websocket-key']
|
|
327
360
|
if (!key) {
|
|
@@ -353,7 +386,7 @@ class WebSocketServer {
|
|
|
353
386
|
if (head && head.length > 0) socket.unshift(head)
|
|
354
387
|
|
|
355
388
|
const clientId = nodeCrypto.randomUUID()
|
|
356
|
-
const client = new WebSocketClient(socket, this, clientId)
|
|
389
|
+
const client = new WebSocketClient(socket, this, clientId, options)
|
|
357
390
|
this.#clients.set(clientId, client)
|
|
358
391
|
|
|
359
392
|
if (params) {
|