odac 1.1.0 → 1.3.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 +42 -0
- package/.agent/rules/project.md +30 -0
- package/.agent/rules/workflow.md +16 -0
- package/.github/workflows/release.yml +68 -1
- package/.github/workflows/test-coverage.yml +6 -5
- package/.husky/pre-commit +10 -0
- package/.husky/pre-push +13 -0
- package/.releaserc.js +1 -1
- package/CHANGELOG.md +99 -0
- package/README.md +16 -0
- package/bin/odac.js +196 -38
- package/client/odac.js +238 -171
- package/docs/backend/01-overview/03-development-server.md +38 -45
- package/docs/backend/02-structure/01-typical-project-layout.md +59 -26
- package/docs/backend/03-config/00-configuration-overview.md +6 -6
- package/docs/backend/03-config/01-database-connection.md +2 -2
- 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/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 +14 -1
- package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
- package/docs/backend/05-controllers/03-controller-classes.md +40 -20
- 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/10-styling-and-tailwind.md +93 -0
- package/docs/backend/08-database/01-getting-started.md +2 -2
- package/docs/backend/10-authentication/03-register.md +1 -1
- package/docs/backend/10-authentication/04-odac-register-forms.md +2 -2
- package/docs/backend/10-authentication/05-session-management.md +15 -1
- package/docs/backend/10-authentication/06-odac-login-forms.md +2 -2
- package/docs/backend/10-authentication/07-magic-links.md +1 -1
- package/docs/index.json +5 -1
- package/jest.config.js +1 -1
- package/package.json +18 -14
- package/src/Auth.js +58 -23
- package/src/Config.js +7 -7
- package/src/Database.js +1 -1
- package/src/Env.js +3 -1
- package/src/Ipc.js +7 -0
- package/src/Lang.js +9 -2
- package/src/Mail.js +19 -9
- package/src/Odac.js +56 -44
- package/src/Request.js +6 -2
- package/src/Route/Cron.js +58 -17
- package/src/Route/Internal.js +1 -1
- package/src/Route.js +375 -125
- package/src/Server.js +40 -3
- package/src/Storage.js +4 -0
- package/src/Token.js +6 -4
- package/src/Validator.js +24 -15
- package/src/Var.js +22 -6
- package/src/View/EarlyHints.js +43 -33
- package/src/View/Form.js +17 -11
- package/src/View.js +68 -12
- package/src/WebSocket.js +45 -12
- package/template/package.json +3 -1
- 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 +88 -0
- package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
- package/test/{framework/Route.test.js → Route.test.js} +105 -1
- package/test/{framework/View → View}/EarlyHints.test.js +1 -1
- package/test/WebSocket.test.js +238 -0
- package/test/scripts/check-coverage.js +4 -4
- package/test/cli/Cli.test.js +0 -36
- 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/Odac.test.js +0 -234
- package/test/core/Process.test.js +0 -156
- package/test/framework/WebSocket.test.js +0 -100
- 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/template/{config.json → odac.json} +0 -0
package/src/Server.js
CHANGED
|
@@ -7,13 +7,17 @@ module.exports = {
|
|
|
7
7
|
init: function () {
|
|
8
8
|
let args = process.argv.slice(2)
|
|
9
9
|
if (args[0] == 'framework' && args[1] == 'run') args = args.slice(2)
|
|
10
|
-
let port = parseInt(args[0]
|
|
10
|
+
let port = parseInt(args[0])
|
|
11
|
+
if (isNaN(port)) port = parseInt(process.env.PORT || '1071')
|
|
11
12
|
|
|
12
13
|
if (cluster.isPrimary) {
|
|
13
|
-
const numCPUs = os.cpus().length
|
|
14
|
+
const numCPUs = Odac.Config.debug ? 1 : os.cpus().length
|
|
14
15
|
let isShuttingDown = false
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
const mode = Odac.Config.debug ? '\x1b[33mDevelopment\x1b[0m' : '\x1b[32mProduction\x1b[0m'
|
|
18
|
+
console.log(
|
|
19
|
+
`Odac Server running on \x1b]8;;http://127.0.0.1:${port}\x1b\\\x1b[4mhttp://127.0.0.1:${port}\x1b[0m\x1b]8;;\x1b\\ in ${mode} mode.`
|
|
20
|
+
)
|
|
17
21
|
|
|
18
22
|
// Start session garbage collector (runs every hour, expires after 7 days)
|
|
19
23
|
Odac.Storage.startSessionGC()
|
|
@@ -68,6 +72,39 @@ module.exports = {
|
|
|
68
72
|
return Odac.Route.request(req, res)
|
|
69
73
|
})
|
|
70
74
|
|
|
75
|
+
/**
|
|
76
|
+
* ENTERPRISE PERFORMANCE CONFIGURATION
|
|
77
|
+
* ------------------------------------
|
|
78
|
+
* 1. Keep-Alive: Set higher than the upstream Load Balancer/Proxy (usually 60s).
|
|
79
|
+
* This prevents the "502 Bad Gateway" race condition where Node closes
|
|
80
|
+
* an idle connection while the proxy attempts to reuse it.
|
|
81
|
+
*/
|
|
82
|
+
server.keepAliveTimeout = 65000 // 65 seconds
|
|
83
|
+
server.headersTimeout = 66000 // 66 seconds (Must be > keepAliveTimeout)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 2. Low Latency: Disable Nagle's Algorithm.
|
|
87
|
+
* We want to send data immediately, even if the packet is small.
|
|
88
|
+
* Critical for sub-millisecond API responses.
|
|
89
|
+
*/
|
|
90
|
+
server.on('connection', socket => {
|
|
91
|
+
socket.setNoDelay(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 3. Connection Rotation: Force reset after 10k requests.
|
|
96
|
+
* - Helps with Load Balancing (clients are forced to reconnect and potentially pick a new pod/worker).
|
|
97
|
+
* - Mitigates long-term memory leaks in the TLS/Socket layer.
|
|
98
|
+
*/
|
|
99
|
+
server.maxRequestsPerSocket = 10000
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 4. Hard Timeout: Kill connection if request processing (headers + body) takes too long.
|
|
103
|
+
* - Defaults to 0 (unlimited) or 5min in older Node.
|
|
104
|
+
* - 30s is more than enough for an API; fail fast if the client is stuck.
|
|
105
|
+
*/
|
|
106
|
+
server.requestTimeout = 30000 // 30 seconds
|
|
107
|
+
|
|
71
108
|
server.on('upgrade', (req, socket, head) => {
|
|
72
109
|
const id = nodeCrypto.randomBytes(16).toString('hex')
|
|
73
110
|
const param = Odac.instance(id, req, null)
|
package/src/Storage.js
CHANGED
|
@@ -22,6 +22,10 @@ class OdacStorage {
|
|
|
22
22
|
this.db = open({
|
|
23
23
|
path: dbPath,
|
|
24
24
|
compression: true
|
|
25
|
+
// CLUSTER SAFETY NOTE:
|
|
26
|
+
// LMDB uses memory-mapped files with OS-level locking logic.
|
|
27
|
+
// Multiple workers can safely read/write to this DB simultaneously.
|
|
28
|
+
// Data committed by Worker A is immediately visible to Worker B.
|
|
25
29
|
})
|
|
26
30
|
this.ready = true
|
|
27
31
|
} catch (error) {
|
package/src/Token.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
2
|
|
|
3
3
|
class Token {
|
|
4
|
+
// CLUSTER SAFETY NOTE:
|
|
5
|
+
// This is a request-scoped local cache (debounce) for performance.
|
|
6
|
+
// Valid tokens represent state persisted in Session (LMDB), shared across all workers.
|
|
4
7
|
confirmed = []
|
|
5
8
|
|
|
6
9
|
constructor(Request) {
|
|
@@ -22,10 +25,9 @@ class Token {
|
|
|
22
25
|
|
|
23
26
|
// - GENERATE TOKEN
|
|
24
27
|
generate() {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.digest('hex')
|
|
28
|
+
// Enterprise Standard: Use CSPRNG (Cryptographically Secure Pseudo-Random Number Generator)
|
|
29
|
+
// Replaced weak MD5(Math.random) with randomBytes(32)
|
|
30
|
+
let token = nodeCrypto.randomBytes(32).toString('hex')
|
|
29
31
|
let tokens = this.Request.session('_token') || []
|
|
30
32
|
tokens.push(token)
|
|
31
33
|
if (tokens.length > 50) tokens = tokens.slice(-50)
|
package/src/Validator.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
const https = require('https')
|
|
2
2
|
const fs = require('fs')
|
|
3
|
-
const
|
|
3
|
+
const fsPromises = fs.promises
|
|
4
4
|
const path = require('path')
|
|
5
5
|
|
|
6
6
|
let disposableDomains = null
|
|
7
|
-
const CACHE_FILE = path.join(
|
|
7
|
+
const CACHE_FILE = path.join(global.__dir || process.cwd(), 'storage', '.cache', 'odac_disposable_domains.conf')
|
|
8
8
|
const SOURCE_URL = 'https://hub.odac.run/blocklist/disposable-emails'
|
|
9
9
|
|
|
10
10
|
async function loadDisposableDomains() {
|
|
@@ -16,16 +16,16 @@ async function loadDisposableDomains() {
|
|
|
16
16
|
|
|
17
17
|
try {
|
|
18
18
|
try {
|
|
19
|
-
const
|
|
19
|
+
const handle = await fsPromises.open(CACHE_FILE, 'r')
|
|
20
20
|
try {
|
|
21
|
-
const stats =
|
|
21
|
+
const stats = await handle.stat()
|
|
22
22
|
const ageInHours = (new Date() - stats.mtime) / (1000 * 60 * 60)
|
|
23
23
|
if (ageInHours < 24) {
|
|
24
|
-
content =
|
|
24
|
+
content = await handle.readFile('utf8')
|
|
25
25
|
shouldUpdate = false
|
|
26
26
|
}
|
|
27
27
|
} finally {
|
|
28
|
-
|
|
28
|
+
await handle.close()
|
|
29
29
|
}
|
|
30
30
|
} catch {
|
|
31
31
|
// Cache error check failed, proceed to validation update
|
|
@@ -48,18 +48,25 @@ async function loadDisposableDomains() {
|
|
|
48
48
|
req.end()
|
|
49
49
|
})
|
|
50
50
|
const tempFile = `${CACHE_FILE}_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
|
51
|
-
|
|
51
|
+
await fsPromises.mkdir(path.dirname(CACHE_FILE), {recursive: true})
|
|
52
|
+
const handle = await fsPromises.open(tempFile, 'wx', 0o600)
|
|
52
53
|
try {
|
|
53
|
-
//
|
|
54
|
+
// SECURITY NOTE: Supply Chain Attack Mitigation
|
|
55
|
+
// We strictly sanitize the content fetched from the remote source before writing it to the local file system.
|
|
56
|
+
// The regex whitelist /[^a-zA-Z0-9.\-\n\r]/g restricts the content to only alphanumeric characters, dots, hyphens, and newlines.
|
|
57
|
+
// This aggressively neutralizes any potential malicious payloads (e.g., specific code injection, shell commands, or scrips)
|
|
58
|
+
// even if the remote source (hub.odac.run) is compromised.
|
|
54
59
|
const sanitizedContent = content.replace(/[^a-zA-Z0-9.\-\n\r]/g, '')
|
|
55
|
-
|
|
60
|
+
await handle.write(sanitizedContent)
|
|
56
61
|
} finally {
|
|
57
|
-
|
|
62
|
+
await handle.close()
|
|
58
63
|
}
|
|
59
|
-
|
|
64
|
+
await fsPromises.rename(tempFile, CACHE_FILE)
|
|
60
65
|
} catch {
|
|
61
|
-
|
|
62
|
-
content =
|
|
66
|
+
try {
|
|
67
|
+
content = await fsPromises.readFile(CACHE_FILE, 'utf8')
|
|
68
|
+
} catch {
|
|
69
|
+
// No cache file available
|
|
63
70
|
}
|
|
64
71
|
}
|
|
65
72
|
}
|
|
@@ -73,7 +80,9 @@ async function loadDisposableDomains() {
|
|
|
73
80
|
})
|
|
74
81
|
}
|
|
75
82
|
} catch (error) {
|
|
76
|
-
console.error(
|
|
83
|
+
console.error(
|
|
84
|
+
JSON.stringify({level: 'error', tag: 'ODAC Validator', message: 'Could not load disposable domains.', error: error.message})
|
|
85
|
+
)
|
|
77
86
|
}
|
|
78
87
|
}
|
|
79
88
|
|
|
@@ -278,7 +287,7 @@ class Validator {
|
|
|
278
287
|
error = true
|
|
279
288
|
} else {
|
|
280
289
|
const userData = Odac.Auth.user(vars[1])
|
|
281
|
-
if (Odac.Var(userData).is('
|
|
290
|
+
if (Odac.Var(userData).is('hash')) {
|
|
282
291
|
error = !Odac.Var(userData).hashCheck(value)
|
|
283
292
|
} else {
|
|
284
293
|
error = value !== userData
|
package/src/Var.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const nodeCrypto = require('crypto')
|
|
3
|
-
const bcrypt = require('bcrypt')
|
|
4
3
|
|
|
5
4
|
class Var {
|
|
6
5
|
#value = null
|
|
@@ -79,17 +78,34 @@ class Var {
|
|
|
79
78
|
return encrypted.toString('base64')
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
hash(
|
|
83
|
-
|
|
81
|
+
hash() {
|
|
82
|
+
const salt = nodeCrypto.randomBytes(16).toString('hex')
|
|
83
|
+
const derivedKey = nodeCrypto.scryptSync(this.#value, salt, 64)
|
|
84
|
+
return `$scrypt$${salt}$${derivedKey.toString('hex')}`
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
hashCheck(check) {
|
|
87
|
-
|
|
88
|
+
if (!this.#value.startsWith('$scrypt$')) return false
|
|
89
|
+
const parts = this.#value.split('$')
|
|
90
|
+
if (parts.length < 4) return false
|
|
91
|
+
|
|
92
|
+
const salt = parts[2]
|
|
93
|
+
const originalHash = Buffer.from(parts[3], 'hex')
|
|
94
|
+
|
|
95
|
+
const derivedKey = nodeCrypto.scryptSync(check, salt, 64)
|
|
96
|
+
return nodeCrypto.timingSafeEqual(originalHash, derivedKey)
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
html() {
|
|
91
100
|
if (this.#value === null || this.#value === undefined) return ''
|
|
92
|
-
|
|
101
|
+
const map = {
|
|
102
|
+
'&': '&',
|
|
103
|
+
'<': '<',
|
|
104
|
+
'>': '>',
|
|
105
|
+
'"': '"',
|
|
106
|
+
"'": '''
|
|
107
|
+
}
|
|
108
|
+
return String(this.#value).replace(/[&<>"']/g, m => map[m])
|
|
93
109
|
}
|
|
94
110
|
|
|
95
111
|
is(...args) {
|
|
@@ -102,7 +118,7 @@ class Var {
|
|
|
102
118
|
if (args.includes('alphaspace')) result = (result || any) && ((any && result) || /^[A-Za-z\s]+$/.test(this.#value))
|
|
103
119
|
if (args.includes('alphanumeric')) result = (result || any) && ((any && result) || /^[A-Za-z0-9]+$/.test(this.#value))
|
|
104
120
|
if (args.includes('alphanumericspace')) result = (result || any) && ((any && result) || /^[A-Za-z0-9\s]+$/.test(this.#value))
|
|
105
|
-
if (args.includes('
|
|
121
|
+
if (args.includes('hash')) result = (result || any) && ((any && result) || /^\$scrypt\$[a-f0-9]+\$[a-f0-9]+$/.test(this.#value))
|
|
106
122
|
if (args.includes('date')) result = (result || any) && ((any && result) || !isNaN(Date.parse(this.#value)))
|
|
107
123
|
if (args.includes('domain')) result = (result || any) && ((any && result) || /^([a-z0-9-]+\.){1,2}[a-z]{2,6}$/i.test(this.#value))
|
|
108
124
|
if (args.includes('email'))
|
package/src/View/EarlyHints.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
1
|
+
const fs = require('fs').promises
|
|
2
2
|
const path = require('path')
|
|
3
3
|
|
|
4
4
|
class EarlyHints {
|
|
@@ -15,61 +15,71 @@ class EarlyHints {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
init() {
|
|
18
|
+
async init() {
|
|
19
19
|
if (this.#initialized) return
|
|
20
20
|
this.#initialized = true
|
|
21
21
|
|
|
22
22
|
if (!this.#config.enabled) return
|
|
23
23
|
|
|
24
|
-
this.#buildManifest()
|
|
24
|
+
await this.#buildManifest()
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
#buildManifest() {
|
|
27
|
+
async #buildManifest() {
|
|
28
28
|
const viewDir = path.join(process.cwd(), 'view')
|
|
29
29
|
const skeletonDir = path.join(process.cwd(), 'skeleton')
|
|
30
30
|
|
|
31
31
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
32
|
+
try {
|
|
33
|
+
await fs.access(viewDir)
|
|
34
|
+
const files = await this.#getAllViewFiles(viewDir)
|
|
35
|
+
await Promise.all(
|
|
36
|
+
files.map(async file => {
|
|
37
|
+
const html = await fs.readFile(file, 'utf8')
|
|
38
|
+
const resources = this.#extractResources(html)
|
|
39
|
+
|
|
40
|
+
const relativePath = path.relative(viewDir, file)
|
|
41
|
+
const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
|
|
42
|
+
|
|
43
|
+
if (resources.length > 0) {
|
|
44
|
+
this.#manifest[viewName] = resources
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
)
|
|
48
|
+
} catch {
|
|
49
|
+
// viewDir might not exist
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
try {
|
|
53
|
+
await fs.access(skeletonDir)
|
|
54
|
+
const files = await this.#getAllViewFiles(skeletonDir)
|
|
55
|
+
await Promise.all(
|
|
56
|
+
files.map(async file => {
|
|
57
|
+
const html = await fs.readFile(file, 'utf8')
|
|
58
|
+
const resources = this.#extractResources(html)
|
|
59
|
+
|
|
60
|
+
const relativePath = path.relative(skeletonDir, file)
|
|
61
|
+
const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
|
|
62
|
+
|
|
63
|
+
if (resources.length > 0) {
|
|
64
|
+
this.#manifest[viewName] = resources
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
} catch {
|
|
69
|
+
// skeletonDir might not exist
|
|
60
70
|
}
|
|
61
71
|
} catch {
|
|
62
72
|
// Silently fail, manifest building is optional
|
|
63
73
|
}
|
|
64
74
|
}
|
|
65
75
|
|
|
66
|
-
#getAllViewFiles(dir, files = []) {
|
|
67
|
-
const entries = fs.
|
|
76
|
+
async #getAllViewFiles(dir, files = []) {
|
|
77
|
+
const entries = await fs.readdir(dir, {withFileTypes: true})
|
|
68
78
|
|
|
69
79
|
for (const entry of entries) {
|
|
70
80
|
const fullPath = path.join(dir, entry.name)
|
|
71
81
|
if (entry.isDirectory()) {
|
|
72
|
-
this.#getAllViewFiles(fullPath, files)
|
|
82
|
+
await this.#getAllViewFiles(fullPath, files)
|
|
73
83
|
} else if (entry.isFile() && entry.name.endsWith('.html')) {
|
|
74
84
|
files.push(fullPath)
|
|
75
85
|
}
|
package/src/View/Form.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
|
17
22
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
this.storeConfig(token, config, Odac, type)
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
content = content.replace(match, generatedForm)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return content
|
|
34
|
+
return this.generateForm(originalHtml, config, token, type)
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
static extractConfig(html, formToken, type) {
|
package/src/View.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
|
|
|
@@ -8,7 +9,6 @@ const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
|
|
|
8
9
|
const CACHE_DIR = './storage/.cache'
|
|
9
10
|
|
|
10
11
|
class View {
|
|
11
|
-
#cache = {}
|
|
12
12
|
#earlyHints = null
|
|
13
13
|
#functions = {
|
|
14
14
|
'{!!': {
|
|
@@ -116,6 +116,8 @@ class View {
|
|
|
116
116
|
} else {
|
|
117
117
|
this.#earlyHints = global.Odac.View.EarlyHints
|
|
118
118
|
}
|
|
119
|
+
global.Odac.View.Form = Form
|
|
120
|
+
this.Form = Form
|
|
119
121
|
}
|
|
120
122
|
|
|
121
123
|
all(name) {
|
|
@@ -147,7 +149,7 @@ class View {
|
|
|
147
149
|
if (this.#part[element]) {
|
|
148
150
|
let viewPath = this.#part[element]
|
|
149
151
|
if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
|
|
150
|
-
if (
|
|
152
|
+
if (await this.#exists(`./view/${element}/${viewPath}.html`)) {
|
|
151
153
|
const html = await this.#render(`./view/${element}/${viewPath}.html`)
|
|
152
154
|
output[element] = html
|
|
153
155
|
|
|
@@ -167,7 +169,7 @@ class View {
|
|
|
167
169
|
if (this.#part[key] && !this.#odac.Request.ajaxLoad.includes(key)) {
|
|
168
170
|
let viewPath = this.#part[key]
|
|
169
171
|
if (viewPath.includes('.')) viewPath = viewPath.replace(/\./g, '/')
|
|
170
|
-
if (
|
|
172
|
+
if (await this.#exists(`./view/${key}/${viewPath}.html`)) {
|
|
171
173
|
try {
|
|
172
174
|
const partHtml = await this.#render(`./view/${key}/${viewPath}.html`)
|
|
173
175
|
const titleMatch = partHtml.match(TITLE_REGEX)
|
|
@@ -205,8 +207,8 @@ class View {
|
|
|
205
207
|
|
|
206
208
|
// Normal page rendering
|
|
207
209
|
let result = ''
|
|
208
|
-
if (this.#part.skeleton &&
|
|
209
|
-
result =
|
|
210
|
+
if (this.#part.skeleton && (await this.#exists(`./skeleton/${this.#part.skeleton}.html`))) {
|
|
211
|
+
result = await this.#readSkeleton(`./skeleton/${this.#part.skeleton}.html`)
|
|
210
212
|
|
|
211
213
|
// Add data-odac-navigate to content wrapper for auto-navigation
|
|
212
214
|
result = this.#addNavigateAttribute(result)
|
|
@@ -215,7 +217,7 @@ class View {
|
|
|
215
217
|
if (['all', 'skeleton'].includes(key)) continue
|
|
216
218
|
if (!this.#part[key]) continue
|
|
217
219
|
if (this.#part[key].includes('.')) this.#part[key] = this.#part[key].replace(/\./g, '/')
|
|
218
|
-
if (
|
|
220
|
+
if (await this.#exists(`./view/${key}/${this.#part[key]}.html`)) {
|
|
219
221
|
result = result.replace(`{{ ${key.toUpperCase()} }}`, await this.#render(`./view/${key}/${this.#part[key]}.html`))
|
|
220
222
|
}
|
|
221
223
|
}
|
|
@@ -227,7 +229,7 @@ class View {
|
|
|
227
229
|
let file = this.#part.all.split('.')
|
|
228
230
|
file.splice(-1, 0, part.toLowerCase())
|
|
229
231
|
file = file.join('/')
|
|
230
|
-
if (
|
|
232
|
+
if (await this.#exists(`./view/${file}.html`)) {
|
|
231
233
|
result = result.replace(`{{ ${part.toUpperCase()} }}`, await this.#render(`./view/${file}.html`))
|
|
232
234
|
}
|
|
233
235
|
}
|
|
@@ -363,10 +365,42 @@ class View {
|
|
|
363
365
|
}
|
|
364
366
|
|
|
365
367
|
async #render(file) {
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
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
|
|
392
|
+
|
|
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
|
+
}
|
|
368
402
|
|
|
369
|
-
if (
|
|
403
|
+
if (content !== null) {
|
|
370
404
|
content = Form.parse(content, this.#odac)
|
|
371
405
|
|
|
372
406
|
const jsBlocks = []
|
|
@@ -451,8 +485,8 @@ class View {
|
|
|
451
485
|
}
|
|
452
486
|
}
|
|
453
487
|
let cache = `${nodeCrypto.createHash('md5').update(file).digest('hex')}`
|
|
454
|
-
|
|
455
|
-
|
|
488
|
+
await fsPromises.mkdir(CACHE_DIR, {recursive: true})
|
|
489
|
+
await fsPromises.writeFile(
|
|
456
490
|
`${CACHE_DIR}/${cache}`,
|
|
457
491
|
`module.exports = async (Odac, get, __) => {\nlet html = '';\n${result}\nreturn html.trim()\n}`
|
|
458
492
|
)
|
|
@@ -550,6 +584,28 @@ class View {
|
|
|
550
584
|
this.#odac.Request.setEarlyHints(hints)
|
|
551
585
|
}
|
|
552
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
|
+
}
|
|
553
609
|
}
|
|
554
610
|
|
|
555
611
|
module.exports = View
|