odac 1.0.1 → 1.2.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/coding.md +27 -0
- package/.agent/rules/memory.md +33 -0
- package/.agent/rules/project.md +30 -0
- package/.agent/rules/workflow.md +16 -0
- package/.github/workflows/auto-pr-description.yml +3 -1
- package/.github/workflows/release.yml +42 -1
- package/.github/workflows/test-coverage.yml +6 -5
- package/.github/workflows/test-publish.yml +36 -0
- package/.husky/pre-commit +10 -0
- package/.husky/pre-push +13 -0
- package/.releaserc.js +3 -3
- package/CHANGELOG.md +184 -0
- package/README.md +53 -34
- package/bin/odac.js +181 -49
- package/client/odac.js +878 -995
- package/docs/backend/01-overview/03-development-server.md +39 -46
- package/docs/backend/02-structure/01-typical-project-layout.md +59 -25
- package/docs/backend/03-config/00-configuration-overview.md +15 -6
- package/docs/backend/03-config/01-database-connection.md +3 -3
- package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
- package/docs/backend/03-config/03-request-timeout.md +1 -1
- package/docs/backend/03-config/04-environment-variables.md +4 -4
- package/docs/backend/03-config/05-early-hints.md +2 -2
- package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
- package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
- package/docs/backend/04-routing/07-cron-jobs.md +17 -1
- package/docs/backend/04-routing/09-websocket.md +29 -0
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
- package/docs/backend/05-controllers/03-controller-classes.md +61 -55
- package/docs/backend/05-forms/01-custom-forms.md +103 -95
- package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
- package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
- package/docs/backend/07-views/02-rendering-a-view.md +1 -1
- package/docs/backend/07-views/03-variables.md +5 -5
- package/docs/backend/07-views/04-request-data.md +1 -1
- package/docs/backend/07-views/08-backend-javascript.md +1 -1
- package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
- package/docs/backend/08-database/01-getting-started.md +100 -0
- package/docs/backend/08-database/02-basics.md +136 -0
- package/docs/backend/08-database/03-advanced.md +84 -0
- package/docs/backend/08-database/04-migrations.md +48 -0
- package/docs/backend/09-validation/01-the-validator-service.md +1 -0
- package/docs/backend/10-authentication/03-register.md +9 -2
- package/docs/backend/10-authentication/04-odac-register-forms.md +48 -48
- package/docs/backend/10-authentication/05-session-management.md +16 -2
- package/docs/backend/10-authentication/06-odac-login-forms.md +50 -50
- package/docs/backend/10-authentication/07-magic-links.md +134 -0
- package/docs/backend/11-mail/01-the-mail-service.md +118 -28
- package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
- package/docs/backend/13-utilities/01-odac-var.md +7 -7
- package/docs/backend/13-utilities/02-ipc.md +73 -0
- package/docs/frontend/01-overview/01-introduction.md +5 -1
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
- package/docs/index.json +21 -125
- package/eslint.config.mjs +5 -47
- package/jest.config.js +1 -1
- package/package.json +16 -7
- package/src/Auth.js +414 -121
- package/src/Config.js +12 -7
- package/src/Database.js +188 -0
- package/src/Env.js +3 -1
- package/src/Ipc.js +337 -0
- package/src/Lang.js +9 -2
- package/src/Mail.js +408 -37
- package/src/Odac.js +105 -40
- package/src/Request.js +71 -49
- package/src/Route/Cron.js +62 -18
- package/src/Route/Internal.js +215 -12
- package/src/Route/Middleware.js +7 -2
- package/src/Route.js +372 -109
- package/src/Server.js +118 -12
- package/src/Storage.js +169 -0
- package/src/Token.js +6 -4
- package/src/Validator.js +95 -3
- package/src/Var.js +22 -6
- package/src/View/EarlyHints.js +43 -33
- package/src/View/Form.js +210 -28
- package/src/View.js +108 -7
- package/src/WebSocket.js +18 -3
- package/template/odac.json +5 -0
- package/template/package.json +3 -1
- package/template/route/www.js +12 -10
- package/template/view/content/home.html +3 -3
- package/template/view/head/main.html +2 -2
- package/test/Client.test.js +168 -0
- package/test/Config.test.js +112 -0
- package/test/Lang.test.js +92 -0
- package/test/Odac.test.js +86 -0
- package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
- package/test/{framework/Route.test.js → Route.test.js} +1 -1
- package/test/{framework/View → View}/EarlyHints.test.js +1 -1
- package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
- package/test/scripts/check-coverage.js +4 -4
- package/docs/backend/08-database/01-database-connection.md +0 -99
- package/docs/backend/08-database/02-using-mysql.md +0 -322
- package/src/Mysql.js +0 -575
- package/template/config.json +0 -5
- package/test/cli/Cli.test.js +0 -36
- package/test/core/Candy.test.js +0 -234
- package/test/core/Commands.test.js +0 -538
- package/test/core/Config.test.js +0 -1432
- package/test/core/Lang.test.js +0 -250
- package/test/core/Process.test.js +0 -156
- package/test/server/Api.test.js +0 -647
- package/test/server/DNS.test.js +0 -2050
- package/test/server/DNS.test.js.bak +0 -2084
- package/test/server/Hub.test.js +0 -497
- package/test/server/Log.test.js +0 -73
- package/test/server/Mail.account.test_.js +0 -460
- package/test/server/Mail.init.test_.js +0 -411
- package/test/server/Mail.test_.js +0 -1340
- package/test/server/SSL.test_.js +0 -1491
- package/test/server/Server.test.js +0 -765
- package/test/server/Service.test_.js +0 -1127
- package/test/server/Subdomain.test.js +0 -440
- package/test/server/Web/Firewall.test.js +0 -175
- package/test/server/Web/Proxy.test.js +0 -397
- package/test/server/Web.test.js +0 -1494
- package/test/server/__mocks__/acme-client.js +0 -17
- package/test/server/__mocks__/bcrypt.js +0 -50
- package/test/server/__mocks__/child_process.js +0 -389
- package/test/server/__mocks__/crypto.js +0 -432
- package/test/server/__mocks__/fs.js +0 -450
- package/test/server/__mocks__/globalOdac.js +0 -227
- package/test/server/__mocks__/http.js +0 -575
- package/test/server/__mocks__/https.js +0 -272
- package/test/server/__mocks__/index.js +0 -249
- package/test/server/__mocks__/mail/server.js +0 -100
- package/test/server/__mocks__/mail/smtp.js +0 -31
- package/test/server/__mocks__/mailparser.js +0 -81
- package/test/server/__mocks__/net.js +0 -369
- package/test/server/__mocks__/node-forge.js +0 -328
- package/test/server/__mocks__/os.js +0 -320
- package/test/server/__mocks__/path.js +0 -291
- package/test/server/__mocks__/selfsigned.js +0 -8
- package/test/server/__mocks__/server/src/mail/server.js +0 -100
- package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
- package/test/server/__mocks__/smtp-server.js +0 -106
- package/test/server/__mocks__/sqlite3.js +0 -394
- package/test/server/__mocks__/testFactories.js +0 -299
- package/test/server/__mocks__/testHelpers.js +0 -363
- package/test/server/__mocks__/tls.js +0 -229
package/src/View/Form.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
|
|
3
3
|
class Form {
|
|
4
|
-
static FORM_TYPES = ['register', 'login', 'form']
|
|
4
|
+
static FORM_TYPES = ['register', 'login', 'magic-login', 'form']
|
|
5
5
|
|
|
6
6
|
static parse(content, Odac) {
|
|
7
7
|
for (const type of this.FORM_TYPES) {
|
|
@@ -12,20 +12,26 @@ class Form {
|
|
|
12
12
|
|
|
13
13
|
static parseFormType(content, Odac, type) {
|
|
14
14
|
const regex = new RegExp(`<odac:${type}[\\s\\S]*?<\\/odac:${type}>`, 'g')
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
return content.replace(regex, match => {
|
|
16
|
+
const formConfig = this.extractConfig(match, null, type)
|
|
17
|
+
const configStr = JSON.stringify(formConfig)
|
|
18
|
+
const matchStr = JSON.stringify(match)
|
|
19
|
+
return `<script:odac>html += await Odac.View.Form.runtime(Odac, '${type}', ${configStr}, ${matchStr});</script:odac>`
|
|
20
|
+
})
|
|
21
|
+
}
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Generates the form at runtime to ensure a fresh token is created and stored
|
|
25
|
+
* in the current session for every request. This prevents "session expired"
|
|
26
|
+
* errors caused by caching the form token in the compiled view.
|
|
27
|
+
*/
|
|
28
|
+
static async runtime(Odac, type, config, originalHtml) {
|
|
29
|
+
const token = nodeCrypto.randomBytes(32).toString('hex')
|
|
30
|
+
config.token = token
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
content = content.replace(match, generatedForm)
|
|
26
|
-
}
|
|
32
|
+
this.storeConfig(token, config, Odac, type)
|
|
27
33
|
|
|
28
|
-
return
|
|
34
|
+
return this.generateForm(originalHtml, config, token, type)
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
static extractConfig(html, formToken, type) {
|
|
@@ -33,6 +39,8 @@ class Form {
|
|
|
33
39
|
return this.extractRegisterConfig(html, formToken)
|
|
34
40
|
} else if (type === 'login') {
|
|
35
41
|
return this.extractLoginConfig(html, formToken)
|
|
42
|
+
} else if (type === 'magic-login') {
|
|
43
|
+
return this.extractMagicLoginConfig(html, formToken)
|
|
36
44
|
} else if (type === 'form') {
|
|
37
45
|
return this.extractFormConfig(html, formToken)
|
|
38
46
|
}
|
|
@@ -43,6 +51,8 @@ class Form {
|
|
|
43
51
|
this.storeRegisterConfig(token, config, Odac)
|
|
44
52
|
} else if (type === 'login') {
|
|
45
53
|
this.storeLoginConfig(token, config, Odac)
|
|
54
|
+
} else if (type === 'magic-login') {
|
|
55
|
+
this.storeMagicLoginConfig(token, config, Odac)
|
|
46
56
|
} else if (type === 'form') {
|
|
47
57
|
this.storeFormConfig(token, config, Odac)
|
|
48
58
|
}
|
|
@@ -53,6 +63,8 @@ class Form {
|
|
|
53
63
|
return this.generateRegisterForm(originalHtml, config, formToken)
|
|
54
64
|
} else if (type === 'login') {
|
|
55
65
|
return this.generateLoginForm(originalHtml, config, formToken)
|
|
66
|
+
} else if (type === 'magic-login') {
|
|
67
|
+
return this.generateMagicLoginForm(originalHtml, config, formToken)
|
|
56
68
|
} else if (type === 'form') {
|
|
57
69
|
return this.generateCustomForm(originalHtml, config, formToken)
|
|
58
70
|
}
|
|
@@ -97,10 +109,10 @@ class Form {
|
|
|
97
109
|
if (idMatch) config.submitId = idMatch[1]
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
const fieldMatches = html.match(/<odac:
|
|
112
|
+
const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
|
|
101
113
|
if (fieldMatches) {
|
|
102
114
|
for (const fieldHtml of fieldMatches) {
|
|
103
|
-
const field = this.
|
|
115
|
+
const field = this.parseInput(fieldHtml)
|
|
104
116
|
if (field) config.fields.push(field)
|
|
105
117
|
}
|
|
106
118
|
}
|
|
@@ -116,8 +128,8 @@ class Form {
|
|
|
116
128
|
return config
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
static
|
|
120
|
-
const fieldTagMatch = html.match(/<odac:
|
|
131
|
+
static parseInput(html) {
|
|
132
|
+
const fieldTagMatch = html.match(/<odac:input([^>]*?)(?:\/>|>)/)
|
|
121
133
|
if (!fieldTagMatch) return null
|
|
122
134
|
|
|
123
135
|
const fieldTag = fieldTagMatch[0]
|
|
@@ -167,6 +179,26 @@ class Form {
|
|
|
167
179
|
}
|
|
168
180
|
}
|
|
169
181
|
|
|
182
|
+
// Capture generic attributes
|
|
183
|
+
const extraAttrs = {}
|
|
184
|
+
const knownAttrs = ['name', 'type', 'placeholder', 'label', 'class', 'id', 'unique', 'skip']
|
|
185
|
+
const attrRegex = /(\w+)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
|
|
186
|
+
let attrMatch
|
|
187
|
+
// Clean tag to just attributes part for safer regex matching if needed,
|
|
188
|
+
// or just run on fieldTag from start
|
|
189
|
+
const attributesString = fieldTag.replace(/^<odac:input/, '').replace(/\/?>$/, '')
|
|
190
|
+
|
|
191
|
+
while ((attrMatch = attrRegex.exec(attributesString))) {
|
|
192
|
+
const key = attrMatch[1]
|
|
193
|
+
// If value is undefined, it's a boolean attribute (e.g. required, autofocus) -> set as true (or empty string)
|
|
194
|
+
const value = attrMatch[3] !== undefined ? attrMatch[3] : attrMatch[4] !== undefined ? attrMatch[4] : ''
|
|
195
|
+
|
|
196
|
+
if (!knownAttrs.includes(key)) {
|
|
197
|
+
extraAttrs[key] = value
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
field.extraAttributes = extraAttrs
|
|
201
|
+
|
|
170
202
|
return field
|
|
171
203
|
}
|
|
172
204
|
|
|
@@ -218,8 +250,8 @@ class Form {
|
|
|
218
250
|
|
|
219
251
|
let innerContent = originalHtml.replace(/<odac:register[^>]*>/, '').replace(/<\/odac:register>/, '')
|
|
220
252
|
|
|
221
|
-
innerContent = innerContent.replace(/<odac:
|
|
222
|
-
const field = this.
|
|
253
|
+
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
254
|
+
const field = this.parseInput(fieldMatch)
|
|
223
255
|
if (!field) return fieldMatch
|
|
224
256
|
return this.generateFieldHtml(field)
|
|
225
257
|
})
|
|
@@ -277,6 +309,23 @@ class Form {
|
|
|
277
309
|
return html
|
|
278
310
|
}
|
|
279
311
|
|
|
312
|
+
static appendExtraAttributes(attrs, field) {
|
|
313
|
+
if (field.extraAttributes) {
|
|
314
|
+
for (const key in field.extraAttributes) {
|
|
315
|
+
const val = field.extraAttributes[key]
|
|
316
|
+
// If val is empty string, render as boolean attribute if typical, or key=""
|
|
317
|
+
// For HTML5 boolean attrs like autofocus, required, checked, readonly, disabled, multiple, selected
|
|
318
|
+
// presence is enough.
|
|
319
|
+
if (val === '') {
|
|
320
|
+
attrs += ` ${key}`
|
|
321
|
+
} else {
|
|
322
|
+
attrs += ` ${key}="${val.replace(/"/g, '"')}"`
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return attrs
|
|
327
|
+
}
|
|
328
|
+
|
|
280
329
|
static buildHtml5Attributes(field) {
|
|
281
330
|
let attrs = ''
|
|
282
331
|
const html5Rules = {
|
|
@@ -363,6 +412,8 @@ class Form {
|
|
|
363
412
|
if (errorMessages.pattern) attrs += ` data-error-pattern="${errorMessages.pattern.replace(/"/g, '"')}"`
|
|
364
413
|
if (errorMessages.email) attrs += ` data-error-email="${errorMessages.email.replace(/"/g, '"')}"`
|
|
365
414
|
|
|
415
|
+
attrs = this.appendExtraAttributes(attrs, field)
|
|
416
|
+
|
|
366
417
|
return attrs
|
|
367
418
|
}
|
|
368
419
|
|
|
@@ -401,10 +452,10 @@ class Form {
|
|
|
401
452
|
if (idMatch) config.submitId = idMatch[1]
|
|
402
453
|
}
|
|
403
454
|
|
|
404
|
-
const fieldMatches = html.match(/<odac:
|
|
455
|
+
const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
|
|
405
456
|
if (fieldMatches) {
|
|
406
457
|
for (const fieldHtml of fieldMatches) {
|
|
407
|
-
const field = this.
|
|
458
|
+
const field = this.parseInput(fieldHtml)
|
|
408
459
|
if (field) config.fields.push(field)
|
|
409
460
|
}
|
|
410
461
|
}
|
|
@@ -435,8 +486,8 @@ class Form {
|
|
|
435
486
|
|
|
436
487
|
let innerContent = originalHtml.replace(/<odac:login[^>]*>/, '').replace(/<\/odac:login>/, '')
|
|
437
488
|
|
|
438
|
-
innerContent = innerContent.replace(/<odac:
|
|
439
|
-
const field = this.
|
|
489
|
+
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
490
|
+
const field = this.parseInput(fieldMatch)
|
|
440
491
|
if (!field) return fieldMatch
|
|
441
492
|
return this.generateFieldHtml(field)
|
|
442
493
|
})
|
|
@@ -519,10 +570,10 @@ class Form {
|
|
|
519
570
|
if (idMatch) config.submitId = idMatch[1]
|
|
520
571
|
}
|
|
521
572
|
|
|
522
|
-
const fieldMatches = html.match(/<odac:
|
|
573
|
+
const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
|
|
523
574
|
if (fieldMatches) {
|
|
524
575
|
for (const fieldHtml of fieldMatches) {
|
|
525
|
-
const field = this.
|
|
576
|
+
const field = this.parseInput(fieldHtml)
|
|
526
577
|
if (field) config.fields.push(field)
|
|
527
578
|
}
|
|
528
579
|
}
|
|
@@ -558,13 +609,14 @@ class Form {
|
|
|
558
609
|
static generateCustomForm(originalHtml, config, formToken) {
|
|
559
610
|
const submitText = config.submitText || 'Submit'
|
|
560
611
|
const submitLoading = config.submitLoading || 'Processing...'
|
|
561
|
-
|
|
612
|
+
// Always post to internal handler, real action is in session config
|
|
613
|
+
const formAction = '/_odac/form'
|
|
562
614
|
const method = config.method || 'POST'
|
|
563
615
|
|
|
564
616
|
let innerContent = originalHtml.replace(/<odac:form[^>]*>/, '').replace(/<\/odac:form>/, '')
|
|
565
617
|
|
|
566
|
-
innerContent = innerContent.replace(/<odac:
|
|
567
|
-
const field = this.
|
|
618
|
+
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
619
|
+
const field = this.parseInput(fieldMatch)
|
|
568
620
|
if (!field) return fieldMatch
|
|
569
621
|
return this.generateFieldHtml(field)
|
|
570
622
|
})
|
|
@@ -584,7 +636,7 @@ class Form {
|
|
|
584
636
|
|
|
585
637
|
innerContent = innerContent.replace(/<odac:set[^>]*\/?>/g, '')
|
|
586
638
|
|
|
587
|
-
let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(
|
|
639
|
+
let formAttrs = `class="odac-custom-form${config.class ? ' ' + escapeHtml(config.class) : ''}" data-odac-form="${escapeHtml(formToken)}" method="${escapeHtml(method)}" action="${escapeHtml(formAction)}" novalidate`
|
|
588
640
|
if (config.id) formAttrs += ` id="${escapeHtml(config.id)}"`
|
|
589
641
|
|
|
590
642
|
let html = `<form ${formAttrs}>\n`
|
|
@@ -595,6 +647,136 @@ class Form {
|
|
|
595
647
|
|
|
596
648
|
return html
|
|
597
649
|
}
|
|
650
|
+
|
|
651
|
+
static extractMagicLoginConfig(html, formToken) {
|
|
652
|
+
const config = {
|
|
653
|
+
token: formToken,
|
|
654
|
+
redirect: null,
|
|
655
|
+
submitText: 'Send Magic Link',
|
|
656
|
+
submitLoading: 'Sending...',
|
|
657
|
+
fields: []
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const tagMatch = html.match(/<odac:magic-login([^>]*)>/)
|
|
661
|
+
if (!tagMatch) return config
|
|
662
|
+
|
|
663
|
+
const tag = tagMatch[0]
|
|
664
|
+
const redirectMatch = tag.match(/redirect=["']([^"']+)["']/)
|
|
665
|
+
const emailLabelMatch = tag.match(/email-label=["']([^"']+)["']/)
|
|
666
|
+
|
|
667
|
+
if (redirectMatch) config.redirect = redirectMatch[1]
|
|
668
|
+
|
|
669
|
+
// Auto-add email field if not manually specified (simplified usage)
|
|
670
|
+
const fieldMatches = html.match(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g)
|
|
671
|
+
|
|
672
|
+
if (fieldMatches) {
|
|
673
|
+
// Custom fields included
|
|
674
|
+
for (const fieldHtml of fieldMatches) {
|
|
675
|
+
const field = this.parseInput(fieldHtml)
|
|
676
|
+
if (field) config.fields.push(field)
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
// Default Email Field
|
|
680
|
+
config.fields.push({
|
|
681
|
+
name: 'email',
|
|
682
|
+
type: 'email',
|
|
683
|
+
placeholder: 'e.g. user@example.com',
|
|
684
|
+
label: emailLabelMatch ? emailLabelMatch[1] : 'Email Address',
|
|
685
|
+
class: '',
|
|
686
|
+
id: null,
|
|
687
|
+
unique: false,
|
|
688
|
+
skip: false,
|
|
689
|
+
validations: [
|
|
690
|
+
{rule: 'required', message: 'Email is required'},
|
|
691
|
+
{rule: 'email', message: 'Invalid email format'}
|
|
692
|
+
]
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const submitMatch = html.match(/<odac:submit([^>/]*)(?:\/?>|>(.*?)<\/odac:submit>)/)
|
|
697
|
+
if (submitMatch) {
|
|
698
|
+
const submitTag = submitMatch[1]
|
|
699
|
+
const textMatch = submitTag.match(/text=["']([^"']+)["']/)
|
|
700
|
+
const loadingMatch = submitTag.match(/loading=["']([^"']+)["']/)
|
|
701
|
+
const classMatch = submitTag.match(/class=["']([^"']+)["']/)
|
|
702
|
+
const styleMatch = submitTag.match(/style=["']([^"']+)["']/)
|
|
703
|
+
const idMatch = submitTag.match(/id=["']([^"']+)["']/)
|
|
704
|
+
|
|
705
|
+
if (textMatch) config.submitText = textMatch[1]
|
|
706
|
+
else if (submitMatch[2]) config.submitText = submitMatch[2].trim()
|
|
707
|
+
|
|
708
|
+
if (loadingMatch) config.submitLoading = loadingMatch[1]
|
|
709
|
+
if (classMatch) config.submitClass = classMatch[1]
|
|
710
|
+
if (styleMatch) config.submitStyle = styleMatch[1]
|
|
711
|
+
if (idMatch) config.submitId = idMatch[1]
|
|
712
|
+
} else {
|
|
713
|
+
// Check for submit-text attribute on main tag if no submit tag
|
|
714
|
+
const submitTextAttr = tag.match(/submit-text=["']([^"']+)["']/)
|
|
715
|
+
if (submitTextAttr) config.submitText = submitTextAttr[1]
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
return config
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
static storeMagicLoginConfig(token, config, Odac) {
|
|
722
|
+
if (!Odac.View) Odac.View = {}
|
|
723
|
+
if (!Odac.View.magicLoginForms) Odac.View.magicLoginForms = {}
|
|
724
|
+
|
|
725
|
+
const formData = {
|
|
726
|
+
config: config,
|
|
727
|
+
created: Date.now(),
|
|
728
|
+
expires: Date.now() + 30 * 60 * 1000,
|
|
729
|
+
sessionId: Odac.Request.session('_client'),
|
|
730
|
+
userAgent: Odac.Request.header('user-agent'),
|
|
731
|
+
ip: Odac.Request.ip
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
Odac.View.magicLoginForms[token] = formData
|
|
735
|
+
Odac.Request.session(`_magic_login_form_${token}`, formData)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
static generateMagicLoginForm(originalHtml, config, formToken) {
|
|
739
|
+
const submitText = config.submitText || 'Send Magic Link'
|
|
740
|
+
const submitLoading = config.submitLoading || 'Sending...'
|
|
741
|
+
|
|
742
|
+
let innerContent = originalHtml.replace(/<odac:magic-login[^>]*>/, '').replace(/<\/odac:magic-login>/, '')
|
|
743
|
+
|
|
744
|
+
// If no custom fields were present in HTML but we added default email in config
|
|
745
|
+
if (!originalHtml.includes('<odac:input')) {
|
|
746
|
+
const emailField = config.fields.find(f => f.name === 'email')
|
|
747
|
+
if (emailField) {
|
|
748
|
+
innerContent += this.generateFieldHtml(emailField)
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
innerContent = innerContent.replace(/<odac:input([^>]*?)(?:\/>|>(?:[\s\S]*?)<\/odac:input>)/g, fieldMatch => {
|
|
752
|
+
const field = this.parseInput(fieldMatch)
|
|
753
|
+
if (!field) return fieldMatch
|
|
754
|
+
return this.generateFieldHtml(field)
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const submitMatch = innerContent.match(/<odac:submit[\s\S]*?(?:<\/odac:submit>|\/?>)/)
|
|
759
|
+
if (submitMatch) {
|
|
760
|
+
let submitAttrs = `type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}"`
|
|
761
|
+
if (config.submitClass) submitAttrs += ` class="${config.submitClass}"`
|
|
762
|
+
if (config.submitStyle) submitAttrs += ` style="${config.submitStyle}"`
|
|
763
|
+
if (config.submitId) submitAttrs += ` id="${config.submitId}"`
|
|
764
|
+
const submitButton = `<button ${submitAttrs}>${submitText}</button>`
|
|
765
|
+
innerContent = innerContent.replace(submitMatch[0], submitButton)
|
|
766
|
+
} else if (!innerContent.includes('type="submit"')) {
|
|
767
|
+
// Auto add submit button if missing
|
|
768
|
+
const submitButton = `<button type="submit" data-submit-text="${submitText}" data-loading-text="${submitLoading}">${submitText}</button>`
|
|
769
|
+
innerContent += `\n${submitButton}`
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
let html = `<form class="odac-magic-login-form" data-odac-magic-login="${formToken}" method="POST" action="/_odac/magic-login" novalidate>\n`
|
|
773
|
+
html += ` <input type="hidden" name="_odac_magic_login_token" value="${formToken}">\n`
|
|
774
|
+
html += innerContent
|
|
775
|
+
html += `\n <span class="odac-form-success" style="display:none;"></span>\n`
|
|
776
|
+
html += `</form>`
|
|
777
|
+
|
|
778
|
+
return html
|
|
779
|
+
}
|
|
598
780
|
}
|
|
599
781
|
|
|
600
782
|
module.exports = Form
|
package/src/View.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
const fs = require('fs')
|
|
3
|
+
const fsPromises = fs.promises
|
|
3
4
|
const Form = require('./View/Form')
|
|
4
5
|
const EarlyHints = require('./View/EarlyHints')
|
|
5
6
|
|
|
7
|
+
const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
|
|
8
|
+
|
|
6
9
|
const CACHE_DIR = './storage/.cache'
|
|
7
10
|
|
|
8
11
|
class View {
|
|
9
|
-
#cache = {}
|
|
10
12
|
#earlyHints = null
|
|
11
13
|
#functions = {
|
|
12
14
|
'{!!': {
|
|
@@ -114,6 +116,8 @@ class View {
|
|
|
114
116
|
} else {
|
|
115
117
|
this.#earlyHints = global.Odac.View.EarlyHints
|
|
116
118
|
}
|
|
119
|
+
global.Odac.View.Form = Form
|
|
120
|
+
this.Form = Form
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
all(name) {
|
|
@@ -140,12 +144,45 @@ class View {
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
// Render requested elements
|
|
147
|
+
let title = null
|
|
143
148
|
for (let element of this.#odac.Request.ajaxLoad) {
|
|
144
149
|
if (this.#part[element]) {
|
|
145
150
|
let viewPath = this.#part[element]
|
|
146
151
|
if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
|
|
147
152
|
if (fs.existsSync(`./view/${element}/${viewPath}.html`)) {
|
|
148
|
-
|
|
153
|
+
const html = await this.#render(`./view/${element}/${viewPath}.html`)
|
|
154
|
+
output[element] = html
|
|
155
|
+
|
|
156
|
+
// Extract title if present inside the part
|
|
157
|
+
const titleMatch = html.match(TITLE_REGEX)
|
|
158
|
+
if (titleMatch && titleMatch[1]) {
|
|
159
|
+
title = titleMatch[1]
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If title not found in parts, try to extract from 'head', 'header' or 'meta' parts
|
|
166
|
+
if (!title) {
|
|
167
|
+
const priorityParts = ['head', 'header', 'meta']
|
|
168
|
+
for (const key of priorityParts) {
|
|
169
|
+
if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
|
|
170
|
+
let viewPath = this.#part[key]
|
|
171
|
+
if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
|
|
172
|
+
if (fs.existsSync(`./view/${key}/${viewPath}.html`)) {
|
|
173
|
+
try {
|
|
174
|
+
const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
|
|
175
|
+
const titleMatch = partHtml.match(TITLE_REGEX)
|
|
176
|
+
if (titleMatch && titleMatch[1]) {
|
|
177
|
+
title = titleMatch[1]
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
} catch (e) {
|
|
181
|
+
if (this.#odac.Config?.debug) {
|
|
182
|
+
console.warn(`Odac: Failed to render part '${key}' while searching for title:`, e)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
149
186
|
}
|
|
150
187
|
}
|
|
151
188
|
}
|
|
@@ -161,6 +198,8 @@ class View {
|
|
|
161
198
|
this.#odac.Request.end({
|
|
162
199
|
output: output,
|
|
163
200
|
variables: variables,
|
|
201
|
+
data: this.#odac.Request.sharedData,
|
|
202
|
+
title: title,
|
|
164
203
|
skeletonChanged: skeletonChanged
|
|
165
204
|
})
|
|
166
205
|
return
|
|
@@ -168,8 +207,8 @@ class View {
|
|
|
168
207
|
|
|
169
208
|
// Normal page rendering
|
|
170
209
|
let result = ''
|
|
171
|
-
if (this.#part.skeleton &&
|
|
172
|
-
result =
|
|
210
|
+
if (this.#part.skeleton && (await this.#exists(`./skeleton/${this.#part.skeleton}.html`))) {
|
|
211
|
+
result = await this.#readSkeleton(`./skeleton/${this.#part.skeleton}.html`)
|
|
173
212
|
|
|
174
213
|
// Add data-odac-navigate to content wrapper for auto-navigation
|
|
175
214
|
result = this.#addNavigateAttribute(result)
|
|
@@ -207,6 +246,14 @@ class View {
|
|
|
207
246
|
this.#earlyHints.cacheHints(routePath, detectedResources)
|
|
208
247
|
}
|
|
209
248
|
}
|
|
249
|
+
|
|
250
|
+
// Inject Shared Data
|
|
251
|
+
const sharedScript = `<script type="application/json" id="odac-data">${JSON.stringify(this.#odac.Request.sharedData || {})}</script>`
|
|
252
|
+
if (result.includes('</body>')) {
|
|
253
|
+
result = result.replace('</body>', `${sharedScript}</body>`)
|
|
254
|
+
} else {
|
|
255
|
+
result += sharedScript
|
|
256
|
+
}
|
|
210
257
|
}
|
|
211
258
|
|
|
212
259
|
this.#odac.Request.header('Content-Type', 'text/html')
|
|
@@ -318,10 +365,42 @@ class View {
|
|
|
318
365
|
}
|
|
319
366
|
|
|
320
367
|
async #render(file) {
|
|
321
|
-
|
|
322
|
-
|
|
368
|
+
if (!global.Odac.View) global.Odac.View = {}
|
|
369
|
+
if (!global.Odac.View.cache) global.Odac.View.cache = {}
|
|
370
|
+
|
|
371
|
+
// Performance: In Production, skip stat check if cached
|
|
372
|
+
if (!this.#odac.Config?.debug && global.Odac.View.cache[file]) {
|
|
373
|
+
try {
|
|
374
|
+
return await require(`${__dir}/${CACHE_DIR}/${global.Odac.View.cache[file].cache}`)(
|
|
375
|
+
this.#odac,
|
|
376
|
+
key => this.#odac.Request.get(key),
|
|
377
|
+
(...args) => this.#odac.Lang.get(...args)
|
|
378
|
+
)
|
|
379
|
+
} catch {
|
|
380
|
+
// Fallback if cache file missing
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let mtime = 0
|
|
385
|
+
let content = null
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const handle = await fsPromises.open(file, 'r')
|
|
389
|
+
try {
|
|
390
|
+
const stats = await handle.stat()
|
|
391
|
+
mtime = stats.mtimeMs
|
|
323
392
|
|
|
324
|
-
|
|
393
|
+
if (global.Odac.View.cache[file]?.mtime !== mtime) {
|
|
394
|
+
content = await handle.readFile('utf8')
|
|
395
|
+
}
|
|
396
|
+
} finally {
|
|
397
|
+
await handle.close()
|
|
398
|
+
}
|
|
399
|
+
} catch {
|
|
400
|
+
return ''
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (content !== null) {
|
|
325
404
|
content = Form.parse(content, this.#odac)
|
|
326
405
|
|
|
327
406
|
const jsBlocks = []
|
|
@@ -505,6 +584,28 @@ class View {
|
|
|
505
584
|
this.#odac.Request.setEarlyHints(hints)
|
|
506
585
|
}
|
|
507
586
|
}
|
|
587
|
+
|
|
588
|
+
async #exists(path) {
|
|
589
|
+
try {
|
|
590
|
+
await fsPromises.access(path)
|
|
591
|
+
return true
|
|
592
|
+
} catch {
|
|
593
|
+
return false
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async #readSkeleton(path) {
|
|
598
|
+
if (!global.Odac.View.skeletons) global.Odac.View.skeletons = {}
|
|
599
|
+
|
|
600
|
+
// In production (debug=false), cache logic
|
|
601
|
+
if (!this.#odac.Config?.debug && global.Odac.View.skeletons[path]) {
|
|
602
|
+
return global.Odac.View.skeletons[path]
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const content = await fsPromises.readFile(path, 'utf8')
|
|
606
|
+
global.Odac.View.skeletons[path] = content
|
|
607
|
+
return content
|
|
608
|
+
}
|
|
508
609
|
}
|
|
509
610
|
|
|
510
611
|
module.exports = View
|
package/src/WebSocket.js
CHANGED
|
@@ -22,11 +22,16 @@ class WebSocketClient {
|
|
|
22
22
|
|
|
23
23
|
constructor(socket, server, id) {
|
|
24
24
|
this.#socket = socket
|
|
25
|
+
this.#socket.pause()
|
|
25
26
|
this.#server = server
|
|
26
27
|
this.#id = id
|
|
27
28
|
this.#setupListeners()
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
resume() {
|
|
32
|
+
this.#socket.resume()
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
get id() {
|
|
31
36
|
return this.#id
|
|
32
37
|
}
|
|
@@ -129,6 +134,7 @@ class WebSocketClient {
|
|
|
129
134
|
break
|
|
130
135
|
case OPCODE.PING:
|
|
131
136
|
this.#sendFrame(OPCODE.PONG, frame.payload)
|
|
137
|
+
this.#emit('ping', frame.payload)
|
|
132
138
|
break
|
|
133
139
|
case OPCODE.PONG:
|
|
134
140
|
this.#emit('pong')
|
|
@@ -152,6 +158,8 @@ class WebSocketClient {
|
|
|
152
158
|
if (this.#closed) return
|
|
153
159
|
this.#closed = true
|
|
154
160
|
|
|
161
|
+
this.#socket.removeAllListeners()
|
|
162
|
+
|
|
155
163
|
for (const room of this.#rooms) {
|
|
156
164
|
this.#server.leaveRoom(this.#id, room)
|
|
157
165
|
}
|
|
@@ -239,6 +247,7 @@ class WebSocketClient {
|
|
|
239
247
|
|
|
240
248
|
this.#sendFrame(OPCODE.CLOSE, payload)
|
|
241
249
|
this.#socket.end()
|
|
250
|
+
this.#socket.removeAllListeners()
|
|
242
251
|
|
|
243
252
|
for (const room of this.#rooms) {
|
|
244
253
|
this.#server.leaveRoom(this.#id, room)
|
|
@@ -330,13 +339,19 @@ class WebSocketServer {
|
|
|
330
339
|
'HTTP/1.1 101 Switching Protocols',
|
|
331
340
|
'Upgrade: websocket',
|
|
332
341
|
'Connection: Upgrade',
|
|
333
|
-
`Sec-WebSocket-Accept: ${acceptKey}
|
|
334
|
-
'',
|
|
335
|
-
''
|
|
342
|
+
`Sec-WebSocket-Accept: ${acceptKey}`
|
|
336
343
|
]
|
|
337
344
|
|
|
345
|
+
if (req.headers['sec-websocket-protocol']) {
|
|
346
|
+
responseHeaders.push(`Sec-WebSocket-Protocol: ${req.headers['sec-websocket-protocol']}`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
responseHeaders.push('', '')
|
|
350
|
+
|
|
338
351
|
socket.write(responseHeaders.join('\r\n'))
|
|
339
352
|
|
|
353
|
+
if (head && head.length > 0) socket.unshift(head)
|
|
354
|
+
|
|
340
355
|
const clientId = nodeCrypto.randomUUID()
|
|
341
356
|
const client = new WebSocketClient(socket, this, clientId)
|
|
342
357
|
this.#clients.set(clientId, client)
|
package/template/package.json
CHANGED
package/template/route/www.js
CHANGED
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
// Controllers are located in controller/page/ directory
|
|
6
6
|
|
|
7
7
|
// Home page - displays welcome message, features, and interactive demos
|
|
8
|
-
|
|
8
|
+
module.exports = function (Odac) {
|
|
9
|
+
Odac.Route.page('/', 'index')
|
|
9
10
|
|
|
10
|
-
// About page - provides information about Odac
|
|
11
|
-
Odac.Route.page('/about', 'about')
|
|
11
|
+
// About page - provides information about Odac
|
|
12
|
+
Odac.Route.page('/about', 'about')
|
|
12
13
|
|
|
13
|
-
// ============================================
|
|
14
|
-
// API Routes
|
|
15
|
-
// ============================================
|
|
16
|
-
// Add your API routes here
|
|
17
|
-
// Example:
|
|
18
|
-
// Odac.Route.post('/api/contact', 'contact')
|
|
19
|
-
// Odac.Route.get('/api/data', 'data')
|
|
14
|
+
// ============================================
|
|
15
|
+
// API Routes
|
|
16
|
+
// ============================================
|
|
17
|
+
// Add your API routes here
|
|
18
|
+
// Example:
|
|
19
|
+
// Odac.Route.post('/api/contact', 'contact')
|
|
20
|
+
// Odac.Route.get('/api/data', 'data')
|
|
21
|
+
}
|
|
@@ -60,9 +60,9 @@
|
|
|
60
60
|
|
|
61
61
|
<div class="structure-item">
|
|
62
62
|
<div class="structure-icon">⚙️</div>
|
|
63
|
-
<h3 class="structure-title">
|
|
63
|
+
<h3 class="structure-title">odac.json</h3>
|
|
64
64
|
<p class="structure-description">Configuration file for database, routes, and other settings.</p>
|
|
65
|
-
<code class="structure-path">
|
|
65
|
+
<code class="structure-path">odac.json</code>
|
|
66
66
|
</div>
|
|
67
67
|
</div>
|
|
68
68
|
</div>
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
<div class="feature-icon">🗄️</div>
|
|
157
157
|
<div class="feature-content">
|
|
158
158
|
<h3>Database Ready</h3>
|
|
159
|
-
<p>MySQL and SQLite support built-in. Configure in <code>
|
|
159
|
+
<p>MySQL and SQLite support built-in. Configure in <code>odac.json</code></p>
|
|
160
160
|
</div>
|
|
161
161
|
</div>
|
|
162
162
|
|