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.
Files changed (143) hide show
  1. package/.agent/rules/coding.md +27 -0
  2. package/.agent/rules/memory.md +33 -0
  3. package/.agent/rules/project.md +30 -0
  4. package/.agent/rules/workflow.md +16 -0
  5. package/.github/workflows/auto-pr-description.yml +3 -1
  6. package/.github/workflows/release.yml +42 -1
  7. package/.github/workflows/test-coverage.yml +6 -5
  8. package/.github/workflows/test-publish.yml +36 -0
  9. package/.husky/pre-commit +10 -0
  10. package/.husky/pre-push +13 -0
  11. package/.releaserc.js +3 -3
  12. package/CHANGELOG.md +184 -0
  13. package/README.md +53 -34
  14. package/bin/odac.js +181 -49
  15. package/client/odac.js +878 -995
  16. package/docs/backend/01-overview/03-development-server.md +39 -46
  17. package/docs/backend/02-structure/01-typical-project-layout.md +59 -25
  18. package/docs/backend/03-config/00-configuration-overview.md +15 -6
  19. package/docs/backend/03-config/01-database-connection.md +3 -3
  20. package/docs/backend/03-config/02-static-route-mapping-optional.md +1 -1
  21. package/docs/backend/03-config/03-request-timeout.md +1 -1
  22. package/docs/backend/03-config/04-environment-variables.md +4 -4
  23. package/docs/backend/03-config/05-early-hints.md +2 -2
  24. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  25. package/docs/backend/04-routing/03-api-and-data-routes.md +18 -0
  26. package/docs/backend/04-routing/07-cron-jobs.md +17 -1
  27. package/docs/backend/04-routing/09-websocket.md +29 -0
  28. package/docs/backend/05-controllers/01-how-to-build-a-controller.md +48 -3
  29. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  30. package/docs/backend/05-controllers/03-controller-classes.md +61 -55
  31. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  32. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  33. package/docs/backend/06-request-and-response/01-the-request-object-what-is-the-user-asking-for.md +17 -0
  34. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  35. package/docs/backend/07-views/03-variables.md +5 -5
  36. package/docs/backend/07-views/04-request-data.md +1 -1
  37. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  38. package/docs/backend/07-views/10-styling-and-tailwind.md +93 -0
  39. package/docs/backend/08-database/01-getting-started.md +100 -0
  40. package/docs/backend/08-database/02-basics.md +136 -0
  41. package/docs/backend/08-database/03-advanced.md +84 -0
  42. package/docs/backend/08-database/04-migrations.md +48 -0
  43. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  44. package/docs/backend/10-authentication/03-register.md +9 -2
  45. package/docs/backend/10-authentication/04-odac-register-forms.md +48 -48
  46. package/docs/backend/10-authentication/05-session-management.md +16 -2
  47. package/docs/backend/10-authentication/06-odac-login-forms.md +50 -50
  48. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  49. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  50. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  51. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  52. package/docs/backend/13-utilities/02-ipc.md +73 -0
  53. package/docs/frontend/01-overview/01-introduction.md +5 -1
  54. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  55. package/docs/index.json +21 -125
  56. package/eslint.config.mjs +5 -47
  57. package/jest.config.js +1 -1
  58. package/package.json +16 -7
  59. package/src/Auth.js +414 -121
  60. package/src/Config.js +12 -7
  61. package/src/Database.js +188 -0
  62. package/src/Env.js +3 -1
  63. package/src/Ipc.js +337 -0
  64. package/src/Lang.js +9 -2
  65. package/src/Mail.js +408 -37
  66. package/src/Odac.js +105 -40
  67. package/src/Request.js +71 -49
  68. package/src/Route/Cron.js +62 -18
  69. package/src/Route/Internal.js +215 -12
  70. package/src/Route/Middleware.js +7 -2
  71. package/src/Route.js +372 -109
  72. package/src/Server.js +118 -12
  73. package/src/Storage.js +169 -0
  74. package/src/Token.js +6 -4
  75. package/src/Validator.js +95 -3
  76. package/src/Var.js +22 -6
  77. package/src/View/EarlyHints.js +43 -33
  78. package/src/View/Form.js +210 -28
  79. package/src/View.js +108 -7
  80. package/src/WebSocket.js +18 -3
  81. package/template/odac.json +5 -0
  82. package/template/package.json +3 -1
  83. package/template/route/www.js +12 -10
  84. package/template/view/content/home.html +3 -3
  85. package/template/view/head/main.html +2 -2
  86. package/test/Client.test.js +168 -0
  87. package/test/Config.test.js +112 -0
  88. package/test/Lang.test.js +92 -0
  89. package/test/Odac.test.js +86 -0
  90. package/test/{framework/middleware.test.js → Route/Middleware.test.js} +2 -2
  91. package/test/{framework/Route.test.js → Route.test.js} +1 -1
  92. package/test/{framework/View → View}/EarlyHints.test.js +1 -1
  93. package/test/{framework/WebSocket.test.js → WebSocket.test.js} +2 -2
  94. package/test/scripts/check-coverage.js +4 -4
  95. package/docs/backend/08-database/01-database-connection.md +0 -99
  96. package/docs/backend/08-database/02-using-mysql.md +0 -322
  97. package/src/Mysql.js +0 -575
  98. package/template/config.json +0 -5
  99. package/test/cli/Cli.test.js +0 -36
  100. package/test/core/Candy.test.js +0 -234
  101. package/test/core/Commands.test.js +0 -538
  102. package/test/core/Config.test.js +0 -1432
  103. package/test/core/Lang.test.js +0 -250
  104. package/test/core/Process.test.js +0 -156
  105. package/test/server/Api.test.js +0 -647
  106. package/test/server/DNS.test.js +0 -2050
  107. package/test/server/DNS.test.js.bak +0 -2084
  108. package/test/server/Hub.test.js +0 -497
  109. package/test/server/Log.test.js +0 -73
  110. package/test/server/Mail.account.test_.js +0 -460
  111. package/test/server/Mail.init.test_.js +0 -411
  112. package/test/server/Mail.test_.js +0 -1340
  113. package/test/server/SSL.test_.js +0 -1491
  114. package/test/server/Server.test.js +0 -765
  115. package/test/server/Service.test_.js +0 -1127
  116. package/test/server/Subdomain.test.js +0 -440
  117. package/test/server/Web/Firewall.test.js +0 -175
  118. package/test/server/Web/Proxy.test.js +0 -397
  119. package/test/server/Web.test.js +0 -1494
  120. package/test/server/__mocks__/acme-client.js +0 -17
  121. package/test/server/__mocks__/bcrypt.js +0 -50
  122. package/test/server/__mocks__/child_process.js +0 -389
  123. package/test/server/__mocks__/crypto.js +0 -432
  124. package/test/server/__mocks__/fs.js +0 -450
  125. package/test/server/__mocks__/globalOdac.js +0 -227
  126. package/test/server/__mocks__/http.js +0 -575
  127. package/test/server/__mocks__/https.js +0 -272
  128. package/test/server/__mocks__/index.js +0 -249
  129. package/test/server/__mocks__/mail/server.js +0 -100
  130. package/test/server/__mocks__/mail/smtp.js +0 -31
  131. package/test/server/__mocks__/mailparser.js +0 -81
  132. package/test/server/__mocks__/net.js +0 -369
  133. package/test/server/__mocks__/node-forge.js +0 -328
  134. package/test/server/__mocks__/os.js +0 -320
  135. package/test/server/__mocks__/path.js +0 -291
  136. package/test/server/__mocks__/selfsigned.js +0 -8
  137. package/test/server/__mocks__/server/src/mail/server.js +0 -100
  138. package/test/server/__mocks__/server/src/mail/smtp.js +0 -31
  139. package/test/server/__mocks__/smtp-server.js +0 -106
  140. package/test/server/__mocks__/sqlite3.js +0 -394
  141. package/test/server/__mocks__/testFactories.js +0 -299
  142. package/test/server/__mocks__/testHelpers.js +0 -363
  143. package/test/server/__mocks__/tls.js +0 -229
package/src/Server.js CHANGED
@@ -1,22 +1,128 @@
1
- const http = require(`http`)
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] ?? '1071')
8
- console.log(`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\\.`)
10
+ let port = parseInt(args[0])
11
+ if (isNaN(port)) port = parseInt(process.env.PORT || '1071')
9
12
 
10
- const server = http.createServer((req, res) => {
11
- return Odac.Route.request(req, res)
12
- })
13
+ if (cluster.isPrimary) {
14
+ const numCPUs = Odac.Config.debug ? 1 : os.cpus().length
15
+ let isShuttingDown = false
13
16
 
14
- server.on('upgrade', (req, socket, head) => {
15
- const id = `${Date.now()}${Math.random().toString(36).substr(2, 9)}`
16
- const param = Odac.instance(id, req, null)
17
- Odac.Route.handleWebSocketUpgrade(req, socket, head, param)
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
- server.listen(port)
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
- let token = nodeCrypto
26
- .createHash('md5')
27
- .update(this.Request.id + Date.now().toString() + Math.random().toString())
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 inverse = vars[0].startsWith('!')
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 (inverse ? vars[0].substr(1) : vars[0]) {
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('bcrypt')) {
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(salt = 10) {
83
- return bcrypt.hashSync(this.#value, bcrypt.genSaltSync(salt))
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
- return bcrypt.compareSync(check, this.#value)
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
- return String(this.#value).replace(/</g, '&lt;').replace(/>/g, '&gt;')
101
+ const map = {
102
+ '&': '&amp;',
103
+ '<': '&lt;',
104
+ '>': '&gt;',
105
+ '"': '&quot;',
106
+ "'": '&#39;'
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('bcrypt')) result = (result || any) && ((any && result) || /^\$2[ayb]\$.{56}$/.test(this.#value))
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'))
@@ -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
- if (fs.existsSync(viewDir)) {
33
- const files = this.#getAllViewFiles(viewDir)
34
- for (const file of files) {
35
- const html = fs.readFileSync(file, 'utf8')
36
- const resources = this.#extractResources(html)
37
-
38
- const relativePath = path.relative(viewDir, file)
39
- const viewName = 'view/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
40
-
41
- if (resources.length > 0) {
42
- this.#manifest[viewName] = resources
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
- if (fs.existsSync(skeletonDir)) {
48
- const files = this.#getAllViewFiles(skeletonDir)
49
- for (const file of files) {
50
- const html = fs.readFileSync(file, 'utf8')
51
- const resources = this.#extractResources(html)
52
-
53
- const relativePath = path.relative(skeletonDir, file)
54
- const viewName = 'skeleton/' + relativePath.replace(/\.html$/, '').replace(/\\/g, '/')
55
-
56
- if (resources.length > 0) {
57
- this.#manifest[viewName] = resources
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.readdirSync(dir, {withFileTypes: true})
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
  }