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/Server.js
CHANGED
|
@@ -1,22 +1,128 @@
|
|
|
1
|
-
const http = require(
|
|
1
|
+
const http = require('http')
|
|
2
|
+
const nodeCrypto = require('crypto')
|
|
3
|
+
const cluster = require('node:cluster')
|
|
4
|
+
const os = require('node:os')
|
|
2
5
|
|
|
3
6
|
module.exports = {
|
|
4
7
|
init: function () {
|
|
5
8
|
let args = process.argv.slice(2)
|
|
6
9
|
if (args[0] == 'framework' && args[1] == 'run') args = args.slice(2)
|
|
7
|
-
let port = parseInt(args[0]
|
|
8
|
-
|
|
10
|
+
let port = parseInt(args[0])
|
|
11
|
+
if (isNaN(port)) port = parseInt(process.env.PORT || '1071')
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
if (cluster.isPrimary) {
|
|
14
|
+
const numCPUs = Odac.Config.debug ? 1 : os.cpus().length
|
|
15
|
+
let isShuttingDown = false
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
})
|
|
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
|
+
)
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
// Start session garbage collector (runs every hour, expires after 7 days)
|
|
23
|
+
Odac.Storage.startSessionGC()
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < numCPUs; i++) {
|
|
26
|
+
cluster.fork()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
cluster.on('exit', () => {
|
|
30
|
+
// Don't restart workers during shutdown
|
|
31
|
+
if (!isShuttingDown) {
|
|
32
|
+
cluster.fork()
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Graceful shutdown handler for primary
|
|
37
|
+
const gracefulShutdown = signal => {
|
|
38
|
+
if (isShuttingDown) return
|
|
39
|
+
isShuttingDown = true
|
|
40
|
+
|
|
41
|
+
console.log(`\n\x1b[33m[Shutdown]\x1b[0m ${signal} received, shutting down gracefully...`)
|
|
42
|
+
|
|
43
|
+
// Disconnect all workers
|
|
44
|
+
for (const id in cluster.workers) {
|
|
45
|
+
cluster.workers[id].send('shutdown')
|
|
46
|
+
cluster.workers[id].disconnect()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let workersAlive = Object.keys(cluster.workers).length
|
|
50
|
+
|
|
51
|
+
cluster.on('exit', () => {
|
|
52
|
+
workersAlive--
|
|
53
|
+
if (workersAlive === 0) {
|
|
54
|
+
console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
|
|
55
|
+
Odac.Storage.close()
|
|
56
|
+
console.log('\x1b[32m[Shutdown]\x1b[0m Storage closed. Goodbye!')
|
|
57
|
+
process.exit(0)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// Force exit after 30 seconds
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
|
|
64
|
+
process.exit(1)
|
|
65
|
+
}, 30000)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
|
69
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|
|
70
|
+
} else {
|
|
71
|
+
const server = http.createServer((req, res) => {
|
|
72
|
+
return Odac.Route.request(req, res)
|
|
73
|
+
})
|
|
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
|
+
|
|
108
|
+
server.on('upgrade', (req, socket, head) => {
|
|
109
|
+
const id = nodeCrypto.randomBytes(16).toString('hex')
|
|
110
|
+
const param = Odac.instance(id, req, null)
|
|
111
|
+
Odac.Route.handleWebSocketUpgrade(req, socket, head, param)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
server.listen(port)
|
|
115
|
+
|
|
116
|
+
// Graceful shutdown handler for worker
|
|
117
|
+
process.on('message', msg => {
|
|
118
|
+
if (msg === 'shutdown') {
|
|
119
|
+
console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Closing server...`)
|
|
120
|
+
server.close(() => {
|
|
121
|
+
console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Server closed.`)
|
|
122
|
+
process.exit(0)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
21
127
|
}
|
|
22
128
|
}
|
package/src/Storage.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
|
|
4
|
+
class OdacStorage {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.db = null
|
|
7
|
+
this.ready = false
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
init() {
|
|
11
|
+
const {open} = require('lmdb')
|
|
12
|
+
|
|
13
|
+
const storagePath = path.join(global.__dir, 'storage')
|
|
14
|
+
const dbPath = path.join(storagePath, 'sessions.db')
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Ensure storage directory exists
|
|
18
|
+
if (!fs.existsSync(storagePath)) {
|
|
19
|
+
fs.mkdirSync(storagePath, {recursive: true})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.db = open({
|
|
23
|
+
path: dbPath,
|
|
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.
|
|
29
|
+
})
|
|
30
|
+
this.ready = true
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('\x1b[31m[Storage Error]\x1b[0m Failed to initialize LMDB:', error.message)
|
|
33
|
+
console.error('\x1b[33m[Storage]\x1b[0m Path:', dbPath)
|
|
34
|
+
this.ready = false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Basic KV Operations ---
|
|
39
|
+
|
|
40
|
+
get(key) {
|
|
41
|
+
if (!this.ready) return null
|
|
42
|
+
return this.db.get(key) ?? null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
put(key, value) {
|
|
46
|
+
if (!this.ready) return false
|
|
47
|
+
return this.db.put(key, value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
remove(key) {
|
|
51
|
+
if (!this.ready) return false
|
|
52
|
+
return this.db.remove(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// --- Range Operations ---
|
|
56
|
+
|
|
57
|
+
getRange(options = {}) {
|
|
58
|
+
if (!this.ready) return []
|
|
59
|
+
return this.db.getRange(options)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getKeys(options = {}) {
|
|
63
|
+
if (!this.ready) return []
|
|
64
|
+
return this.db.getKeys(options)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Session Garbage Collector ---
|
|
68
|
+
|
|
69
|
+
startSessionGC(intervalMs = 60 * 60 * 1000, expirationMs = 7 * 24 * 60 * 60 * 1000) {
|
|
70
|
+
if (!this.ready) {
|
|
71
|
+
console.warn('[Storage] GC not started: Storage not ready')
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BATCH_THRESHOLD = 10000
|
|
76
|
+
const BATCH_SIZE = 1000
|
|
77
|
+
|
|
78
|
+
return setInterval(() => {
|
|
79
|
+
try {
|
|
80
|
+
// Count sessions to decide mode
|
|
81
|
+
let sessionCount = 0
|
|
82
|
+
// eslint-disable-next-line
|
|
83
|
+
for (const _ of this.db.getKeys({start: 'sess:', end: 'sess:~', limit: BATCH_THRESHOLD + 1})) {
|
|
84
|
+
sessionCount++
|
|
85
|
+
if (sessionCount > BATCH_THRESHOLD) break
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (sessionCount > BATCH_THRESHOLD) {
|
|
89
|
+
this._cleanSessionsBatch(expirationMs, BATCH_SIZE)
|
|
90
|
+
} else {
|
|
91
|
+
this._cleanSessionsSimple(expirationMs)
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('\x1b[31m[Storage GC Error]\x1b[0m', error.message)
|
|
95
|
+
}
|
|
96
|
+
}, intervalMs)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Simple mode: Load all sessions at once (fast for small datasets)
|
|
100
|
+
_cleanSessionsSimple(expirationMs) {
|
|
101
|
+
const now = Date.now()
|
|
102
|
+
let count = 0
|
|
103
|
+
|
|
104
|
+
for (const {key, value} of this.db.getRange({start: 'sess:', end: 'sess:~', snapshot: false})) {
|
|
105
|
+
if (key.endsWith(':_created') && now - value > expirationMs) {
|
|
106
|
+
const prefix = key.replace(':_created', '')
|
|
107
|
+
for (const subKey of this.db.getKeys({start: prefix, end: prefix + '~'})) {
|
|
108
|
+
this.db.remove(subKey)
|
|
109
|
+
}
|
|
110
|
+
count++
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (count > 0) {
|
|
115
|
+
console.log(`\x1b[36m[Storage GC]\x1b[0m Cleaned ${count} expired sessions.`)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Batch mode: Process sessions in chunks (memory-safe for large datasets)
|
|
120
|
+
_cleanSessionsBatch(expirationMs, batchSize) {
|
|
121
|
+
const now = Date.now()
|
|
122
|
+
let count = 0
|
|
123
|
+
let cursor = 'sess:'
|
|
124
|
+
let hasMore = true
|
|
125
|
+
|
|
126
|
+
console.log('\x1b[36m[Storage GC]\x1b[0m Running in batch mode...')
|
|
127
|
+
|
|
128
|
+
while (hasMore) {
|
|
129
|
+
hasMore = false
|
|
130
|
+
let lastKey = null
|
|
131
|
+
|
|
132
|
+
for (const {key, value} of this.db.getRange({start: cursor, end: 'sess:~', limit: batchSize, snapshot: false})) {
|
|
133
|
+
lastKey = key
|
|
134
|
+
hasMore = true
|
|
135
|
+
|
|
136
|
+
if (key.endsWith(':_created') && now - value > expirationMs) {
|
|
137
|
+
const prefix = key.replace(':_created', '')
|
|
138
|
+
for (const subKey of this.db.getKeys({start: prefix, end: prefix + '~'})) {
|
|
139
|
+
this.db.remove(subKey)
|
|
140
|
+
}
|
|
141
|
+
count++
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (lastKey) {
|
|
146
|
+
cursor = lastKey + '\0'
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (count > 0) {
|
|
151
|
+
console.log(`\x1b[36m[Storage GC]\x1b[0m Cleaned ${count} expired sessions (batch mode).`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Utility ---
|
|
156
|
+
|
|
157
|
+
close() {
|
|
158
|
+
if (this.db) {
|
|
159
|
+
this.db.close()
|
|
160
|
+
this.ready = false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
isReady() {
|
|
165
|
+
return this.ready
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = new OdacStorage()
|
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,3 +1,82 @@
|
|
|
1
|
+
const https = require('https')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
let disposableDomains = null
|
|
7
|
+
const CACHE_FILE = path.join(os.tmpdir(), 'odac_disposable_domains.conf')
|
|
8
|
+
const SOURCE_URL = 'https://hub.odac.run/blocklist/disposable-emails'
|
|
9
|
+
|
|
10
|
+
async function loadDisposableDomains() {
|
|
11
|
+
if (disposableDomains instanceof Set) return
|
|
12
|
+
|
|
13
|
+
disposableDomains = new Set()
|
|
14
|
+
let content = ''
|
|
15
|
+
let shouldUpdate = true
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
try {
|
|
19
|
+
const fd = fs.openSync(CACHE_FILE, 'r')
|
|
20
|
+
try {
|
|
21
|
+
const stats = fs.fstatSync(fd)
|
|
22
|
+
const ageInHours = (new Date() - stats.mtime) / (1000 * 60 * 60)
|
|
23
|
+
if (ageInHours < 24) {
|
|
24
|
+
content = fs.readFileSync(fd, 'utf8')
|
|
25
|
+
shouldUpdate = false
|
|
26
|
+
}
|
|
27
|
+
} finally {
|
|
28
|
+
fs.closeSync(fd)
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Cache error check failed, proceed to validation update
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (shouldUpdate) {
|
|
35
|
+
try {
|
|
36
|
+
content = await new Promise((resolve, reject) => {
|
|
37
|
+
const req = https.get(SOURCE_URL, res => {
|
|
38
|
+
if (res.statusCode !== 200) {
|
|
39
|
+
res.resume()
|
|
40
|
+
reject(new Error(`Failed to fetch: ${res.statusCode}`))
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
let data = ''
|
|
44
|
+
res.on('data', chunk => (data += chunk))
|
|
45
|
+
res.on('end', () => resolve(data))
|
|
46
|
+
})
|
|
47
|
+
req.on('error', reject)
|
|
48
|
+
req.end()
|
|
49
|
+
})
|
|
50
|
+
const tempFile = `${CACHE_FILE}_${Date.now()}_${Math.random().toString(36).slice(2)}`
|
|
51
|
+
const fd = fs.openSync(tempFile, 'wx')
|
|
52
|
+
try {
|
|
53
|
+
// Sanitize content before writing to file to avoid injection attacks
|
|
54
|
+
const sanitizedContent = content.replace(/[^a-zA-Z0-9.\-\n\r]/g, '')
|
|
55
|
+
fs.writeSync(fd, sanitizedContent)
|
|
56
|
+
} finally {
|
|
57
|
+
fs.closeSync(fd)
|
|
58
|
+
}
|
|
59
|
+
fs.renameSync(tempFile, CACHE_FILE)
|
|
60
|
+
} catch {
|
|
61
|
+
if (fs.existsSync(CACHE_FILE)) {
|
|
62
|
+
content = fs.readFileSync(CACHE_FILE, 'utf8')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (content) {
|
|
68
|
+
content.split('\n').forEach(line => {
|
|
69
|
+
const domain = line.trim().toLowerCase()
|
|
70
|
+
if (domain && !domain.startsWith('#')) {
|
|
71
|
+
disposableDomains.add(domain)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('Validator Warning: Could not load disposable domains.', error.message)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
1
80
|
class Validator {
|
|
2
81
|
#checklist = {}
|
|
3
82
|
#completed = false
|
|
@@ -88,9 +167,12 @@ class Validator {
|
|
|
88
167
|
} else {
|
|
89
168
|
for (const rule of rules.includes('|') ? rules.split('|') : [rules]) {
|
|
90
169
|
let vars = rule.split(':')
|
|
91
|
-
let
|
|
170
|
+
let ruleName = vars[0].trim()
|
|
171
|
+
let inverse = ruleName.startsWith('!')
|
|
172
|
+
if (inverse) ruleName = ruleName.substr(1)
|
|
173
|
+
|
|
92
174
|
if (!error) {
|
|
93
|
-
switch (
|
|
175
|
+
switch (ruleName) {
|
|
94
176
|
case 'required':
|
|
95
177
|
error = value === undefined || value === '' || value === null
|
|
96
178
|
break
|
|
@@ -196,7 +278,7 @@ class Validator {
|
|
|
196
278
|
error = true
|
|
197
279
|
} else {
|
|
198
280
|
const userData = Odac.Auth.user(vars[1])
|
|
199
|
-
if (Odac.Var(userData).is('
|
|
281
|
+
if (Odac.Var(userData).is('hash')) {
|
|
200
282
|
error = !Odac.Var(userData).hashCheck(value)
|
|
201
283
|
} else {
|
|
202
284
|
error = value !== userData
|
|
@@ -204,6 +286,9 @@ class Validator {
|
|
|
204
286
|
}
|
|
205
287
|
break
|
|
206
288
|
}
|
|
289
|
+
case 'disposable':
|
|
290
|
+
error = value && value !== '' && !(await Validator.isDisposable(value))
|
|
291
|
+
break
|
|
207
292
|
}
|
|
208
293
|
if (inverse) error = !error
|
|
209
294
|
}
|
|
@@ -220,6 +305,13 @@ class Validator {
|
|
|
220
305
|
this.#completed = true
|
|
221
306
|
}
|
|
222
307
|
|
|
308
|
+
static async isDisposable(email) {
|
|
309
|
+
if (!email || typeof email !== 'string') return false
|
|
310
|
+
await loadDisposableDomains()
|
|
311
|
+
const domain = email.split('@').pop().toLowerCase()
|
|
312
|
+
return disposableDomains && disposableDomains.has(domain)
|
|
313
|
+
}
|
|
314
|
+
|
|
223
315
|
var(name, value = null) {
|
|
224
316
|
if (this.#completed) this.#completed = false
|
|
225
317
|
this.#method = 'VAR'
|
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
|
}
|