odac 1.0.0 → 1.1.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 (61) hide show
  1. package/.github/workflows/auto-pr-description.yml +3 -1
  2. package/CHANGELOG.md +127 -0
  3. package/README.md +39 -36
  4. package/bin/odac.js +1 -31
  5. package/client/odac.js +871 -994
  6. package/docs/backend/01-overview/03-development-server.md +7 -7
  7. package/docs/backend/02-structure/01-typical-project-layout.md +1 -0
  8. package/docs/backend/03-config/00-configuration-overview.md +9 -0
  9. package/docs/backend/03-config/01-database-connection.md +1 -1
  10. package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
  11. package/docs/backend/04-routing/09-websocket.md +29 -0
  12. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
  13. package/docs/backend/05-controllers/03-controller-classes.md +27 -41
  14. package/docs/backend/05-forms/01-custom-forms.md +103 -95
  15. package/docs/backend/05-forms/02-automatic-database-insert.md +21 -21
  16. package/docs/backend/07-views/02-rendering-a-view.md +1 -1
  17. package/docs/backend/07-views/03-variables.md +5 -5
  18. package/docs/backend/07-views/04-request-data.md +1 -1
  19. package/docs/backend/07-views/08-backend-javascript.md +1 -1
  20. package/docs/backend/08-database/01-getting-started.md +100 -0
  21. package/docs/backend/08-database/02-basics.md +136 -0
  22. package/docs/backend/08-database/03-advanced.md +84 -0
  23. package/docs/backend/08-database/04-migrations.md +48 -0
  24. package/docs/backend/09-validation/01-the-validator-service.md +1 -0
  25. package/docs/backend/10-authentication/03-register.md +8 -1
  26. package/docs/backend/10-authentication/04-odac-register-forms.md +46 -46
  27. package/docs/backend/10-authentication/05-session-management.md +1 -1
  28. package/docs/backend/10-authentication/06-odac-login-forms.md +48 -48
  29. package/docs/backend/10-authentication/07-magic-links.md +134 -0
  30. package/docs/backend/11-mail/01-the-mail-service.md +118 -28
  31. package/docs/backend/12-streaming/01-streaming-overview.md +2 -2
  32. package/docs/backend/13-utilities/01-odac-var.md +7 -7
  33. package/docs/backend/13-utilities/02-ipc.md +73 -0
  34. package/docs/frontend/01-overview/01-introduction.md +5 -1
  35. package/docs/frontend/02-ajax-navigation/01-quick-start.md +1 -1
  36. package/docs/index.json +16 -124
  37. package/eslint.config.mjs +5 -47
  38. package/package.json +9 -4
  39. package/src/Auth.js +362 -104
  40. package/src/Config.js +7 -2
  41. package/src/Database.js +188 -0
  42. package/src/Ipc.js +330 -0
  43. package/src/Mail.js +408 -37
  44. package/src/Odac.js +65 -9
  45. package/src/Request.js +70 -48
  46. package/src/Route/Cron.js +4 -1
  47. package/src/Route/Internal.js +214 -11
  48. package/src/Route/Middleware.js +7 -2
  49. package/src/Route.js +106 -26
  50. package/src/Server.js +80 -11
  51. package/src/Storage.js +165 -0
  52. package/src/Validator.js +94 -2
  53. package/src/View/Form.js +193 -17
  54. package/src/View.js +46 -1
  55. package/src/WebSocket.js +18 -3
  56. package/template/config.json +1 -1
  57. package/template/route/www.js +12 -10
  58. package/test/core/{Candy.test.js → Odac.test.js} +2 -2
  59. package/docs/backend/08-database/01-database-connection.md +0 -99
  60. package/docs/backend/08-database/02-using-mysql.md +0 -322
  61. package/src/Mysql.js +0 -575
package/src/Route.js CHANGED
@@ -74,7 +74,7 @@ class Route {
74
74
  post: (path, authFile, file) => this.authPost(path, authFile, file),
75
75
  get: (path, authFile, file) => this.authGet(path, authFile, file),
76
76
  ws: (path, handler, options) => this.authWs(path, handler, options),
77
- use: (...middlewares) => new MiddlewareChain(this, [...middlewares.flat()])
77
+ use: (...middlewares) => new MiddlewareChain(this, [...middlewares.flat()], true)
78
78
  }
79
79
 
80
80
  async #runMiddlewares(Odac, middlewares) {
@@ -90,8 +90,13 @@ class Route {
90
90
 
91
91
  const result = await middleware(Odac)
92
92
 
93
+ if (Odac.Request.res.finished) {
94
+ return false
95
+ }
96
+
93
97
  if (result === false) {
94
- return Odac.Request.abort(403)
98
+ await Odac.Request.abort(403)
99
+ return false
95
100
  }
96
101
 
97
102
  if (result !== undefined && result !== true) {
@@ -101,15 +106,15 @@ class Route {
101
106
  }
102
107
 
103
108
  async #executeController(Odac, controller) {
104
- const middlewareResult = await this.#runMiddlewares(Odac, controller.middlewares)
105
- if (middlewareResult !== undefined) return middlewareResult
106
-
107
109
  if (controller.params) {
108
110
  for (let key in controller.params) {
109
111
  Odac.Request.data.url[key] = controller.params[key]
110
112
  }
111
113
  }
112
114
 
115
+ const middlewareResult = await this.#runMiddlewares(Odac, controller.middlewares)
116
+ if (middlewareResult !== undefined) return middlewareResult
117
+
113
118
  if (typeof controller.cache === 'function') {
114
119
  return controller.cache(Odac)
115
120
  }
@@ -153,7 +158,6 @@ class Route {
153
158
  Odac.Request.clientSkeleton = Odac.Request.header('X-Odac-Skeleton')
154
159
  }
155
160
  if (Odac.Config && Odac.Config.route && Odac.Config.route[url]) {
156
- Odac.Config.route[url] = Odac.Config.route[url].replace('${odac}', `${__dir}/node_modules/odac`)
157
161
  if (fs.existsSync(Odac.Config.route[url])) {
158
162
  let stat = fs.lstatSync(Odac.Config.route[url])
159
163
  if (stat.isFile()) {
@@ -174,6 +178,10 @@ class Route {
174
178
  if (controller) {
175
179
  if (!method.startsWith('#') || (await Odac.Auth.check())) {
176
180
  Odac.Request.header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
181
+ Odac.Request.setSession()
182
+ const page = controller.cache?.file || controller.file
183
+ if (typeof page === 'string') Odac.Request.page = page
184
+
177
185
  if (
178
186
  ['post', 'get'].includes(Odac.Request.method) &&
179
187
  controller.token &&
@@ -187,12 +195,16 @@ class Route {
187
195
  }
188
196
  let authPageController = this.#controller(Odac.Request.route, '#page', url)
189
197
  if (authPageController && (await Odac.Auth.check())) {
190
- Odac.Request.page = authPageController.cache?.file || authPageController.file
198
+ Odac.Request.setSession()
199
+ const page = authPageController.cache?.file || authPageController.file
200
+ if (typeof page === 'string') Odac.Request.page = page
191
201
  return await this.#executeController(Odac, authPageController)
192
202
  }
193
203
  let pageController = this.#controller(Odac.Request.route, 'page', url)
194
204
  if (pageController) {
195
- Odac.Request.page = pageController.cache?.file || pageController.file
205
+ Odac.Request.setSession()
206
+ const page = pageController.cache?.file || pageController.file
207
+ if (typeof page === 'string') Odac.Request.page = page
196
208
  return await this.#executeController(Odac, pageController)
197
209
  }
198
210
  if (url && !url.includes('/../') && fs.existsSync(`${__dir}/public${url}`)) {
@@ -206,9 +218,10 @@ class Route {
206
218
  Odac.Request.header('Content-Type', type)
207
219
  Odac.Request.header('Cache-Control', 'public, max-age=31536000')
208
220
  Odac.Request.header('Content-Length', stat.size)
209
- return fs.readFileSync(`${__dir}/public${url}`)
221
+ return fs.createReadStream(`${__dir}/public${url}`)
210
222
  }
211
223
  }
224
+
212
225
  return Odac.Request.abort(404)
213
226
  }
214
227
 
@@ -237,7 +250,8 @@ class Route {
237
250
  params: params,
238
251
  cache: this.routes[route][method][key].cache,
239
252
  token: this.routes[route][method][key].token,
240
- middlewares: this.routes[route][method][key].middlewares
253
+ middlewares: this.routes[route][method][key].middlewares,
254
+ file: this.routes[route][method][key].file
241
255
  }
242
256
  }
243
257
  return false
@@ -268,18 +282,22 @@ class Route {
268
282
  if (this.loading) return
269
283
  this.loading = true
270
284
  this.#loadMiddlewares()
271
- for (const file of fs.readdirSync(`${__dir}/controller/`)) {
272
- if (!file.endsWith('.js')) continue
273
- let name = file.replace('.js', '')
274
- if (!Odac.Route.class) Odac.Route.class = {}
275
- if (Odac.Route.class[name]) {
276
- if (Odac.Route.class[name].mtime >= fs.statSync(Odac.Route.class[name].path).mtimeMs + 1000) continue
277
- delete require.cache[require.resolve(Odac.Route.class[name].path)]
278
- }
279
- Odac.Route.class[name] = {
280
- path: `${__dir}/controller/${file}`,
281
- mtime: fs.statSync(`${__dir}/controller/${file}`).mtimeMs,
282
- module: require(`${__dir}/controller/${file}`)
285
+ const classDir = `${__dir}/class/`
286
+ if (fs.existsSync(classDir)) {
287
+ for (const file of fs.readdirSync(classDir)) {
288
+ if (!file.endsWith('.js')) continue
289
+ let name = file.replace('.js', '')
290
+ if (!Odac.Route.class) Odac.Route.class = {}
291
+ if (Odac.Route.class[name]) {
292
+ const fileStat = fs.statSync(Odac.Route.class[name].path)
293
+ if (Odac.Route.class[name].mtime >= fileStat.mtimeMs || Date.now() < fileStat.mtimeMs + 1000) continue
294
+ delete require.cache[require.resolve(Odac.Route.class[name].path)]
295
+ }
296
+ Odac.Route.class[name] = {
297
+ path: `${__dir}/class/${file}`,
298
+ mtime: fs.statSync(`${__dir}/class/${file}`).mtimeMs,
299
+ module: require(`${__dir}/class/${file}`)
300
+ }
283
301
  }
284
302
  }
285
303
  let dir = fs.readdirSync(`${__dir}/route/`)
@@ -290,7 +308,10 @@ class Route {
290
308
  if (!routes2[Odac.Route.buff] || routes2[Odac.Route.buff] < mtime - 1000) {
291
309
  delete require.cache[require.resolve(`${__dir}/route/${file}`)]
292
310
  routes2[Odac.Route.buff] = mtime
293
- require(`${__dir}/route/${file}`)
311
+ const routeModule = require(`${__dir}/route/${file}`)
312
+ if (typeof routeModule === 'function') {
313
+ routeModule(Odac)
314
+ }
294
315
  }
295
316
  for (const type of ['page', '#page', 'post', '#post', 'get', '#get', 'error']) {
296
317
  if (!this.routes[Odac.Route.buff]) continue
@@ -354,7 +375,7 @@ class Route {
354
375
  )
355
376
 
356
377
  this.set(
357
- ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'],
378
+ 'POST',
358
379
  '/_odac/form',
359
380
  async Odac => {
360
381
  const csrfToken = await Odac.request('_token')
@@ -375,6 +396,28 @@ class Route {
375
396
  {token: true}
376
397
  )
377
398
 
399
+ this.set(
400
+ 'POST',
401
+ '/_odac/magic-login',
402
+ async Odac => {
403
+ const csrfToken = await Odac.request('_token')
404
+ if (!csrfToken || !Odac.token(csrfToken)) {
405
+ return Odac.Request.abort(401)
406
+ }
407
+ return await Internal.magicLogin(Odac)
408
+ },
409
+ {token: true}
410
+ )
411
+
412
+ this.set(
413
+ 'GET',
414
+ '/_odac/magic-verify',
415
+ async Odac => {
416
+ return await Internal.magicVerify(Odac)
417
+ },
418
+ {token: false}
419
+ )
420
+
378
421
  delete Odac.Route.buff
379
422
  }
380
423
 
@@ -387,6 +430,11 @@ class Route {
387
430
  if (result instanceof Promise) result = await result
388
431
  const Stream = require('./Stream.js')
389
432
  if (result instanceof Stream) return
433
+ if (result && typeof result.pipe === 'function') {
434
+ param.Request.print()
435
+ result.pipe(param.Request.res)
436
+ return
437
+ }
390
438
  if (param.Request.res.finished || param.Request.res.writableEnded) {
391
439
  param.cleanup()
392
440
  return
@@ -453,7 +501,10 @@ class Route {
453
501
  this.routes[Odac.Route.buff][type][url].path = path
454
502
  this.routes[Odac.Route.buff][type][url].loaded = routes2[Odac.Route.buff]
455
503
  this.routes[Odac.Route.buff][type][url].token = options.token ?? true
504
+
456
505
  this.routes[Odac.Route.buff][type][url].middlewares = this._pendingMiddlewares.length > 0 ? [...this._pendingMiddlewares] : undefined
506
+ } else if (file && typeof file === 'string') {
507
+ console.error(`\x1b[31m[Odac]\x1b[0m Controller not found: \x1b[33m${path}\x1b[0m`)
457
508
  }
458
509
 
459
510
  return this
@@ -462,6 +513,7 @@ class Route {
462
513
  page(path, file) {
463
514
  if (typeof file === 'object' && !Array.isArray(file)) {
464
515
  this.set('page', path, _odac => {
516
+ _odac.set(file)
465
517
  _odac.View.set(file)
466
518
  return
467
519
  })
@@ -482,16 +534,20 @@ class Route {
482
534
  }
483
535
 
484
536
  authPage(path, authFile, file) {
485
- if (typeof authFile === 'object' && !Array.isArray(authFile)) {
537
+ if (typeof authFile === 'object' && authFile !== null && !Array.isArray(authFile)) {
486
538
  this.set('#page', path, _odac => {
539
+ _odac.set(authFile)
487
540
  _odac.View.set(authFile)
488
541
  return
489
542
  })
490
543
  if (typeof file === 'object' && !Array.isArray(file)) {
491
544
  this.set('page', path, _odac => {
545
+ _odac.set(file)
492
546
  _odac.View.set(file)
493
547
  return
494
548
  })
549
+ } else if (file) {
550
+ this.set('page', path, file)
495
551
  }
496
552
  return this
497
553
  }
@@ -499,6 +555,7 @@ class Route {
499
555
  if (file) {
500
556
  if (typeof file === 'object' && !Array.isArray(file)) {
501
557
  this.set('page', path, _odac => {
558
+ _odac.set(file)
502
559
  _odac.View.set(file)
503
560
  return
504
561
  })
@@ -546,6 +603,26 @@ class Route {
546
603
  const {token = true} = options
547
604
  const requireAuth = type === '#ws'
548
605
 
606
+ if (typeof handler !== 'function') {
607
+ let path = `${__dir}/controller/${type.replace('#', '')}/${handler}.js`
608
+ if (typeof handler === 'string' && handler.includes('.')) {
609
+ let arr = handler.split('.')
610
+ path = `${__dir}/controller/${arr[0]}/${type.replace('#', '')}/${arr.slice(1).join('.')}.js`
611
+ }
612
+
613
+ if (fs.existsSync(path)) {
614
+ handler = require(path)
615
+ } else {
616
+ console.error(`\x1b[31m[Odac]\x1b[0m WebSocket Controller not found: \x1b[33m${path}\x1b[0m`)
617
+ return
618
+ }
619
+ }
620
+
621
+ if (typeof handler !== 'function') {
622
+ console.error(`\x1b[31m[Odac]\x1b[0m Invalid WebSocket handler (not a function).`)
623
+ return
624
+ }
625
+
549
626
  const wrappedHandler = async (ws, Odac) => {
550
627
  Odac.ws = ws
551
628
 
@@ -603,7 +680,10 @@ class Route {
603
680
  }
604
681
  }
605
682
  }
606
- return handler(Odac)
683
+ const res = handler(Odac)
684
+ if (res instanceof Promise) await res
685
+ ws.resume()
686
+ return res
607
687
  }
608
688
 
609
689
  this.#wsServer.route(path, wrappedHandler)
package/src/Server.js CHANGED
@@ -1,22 +1,91 @@
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
10
  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\\.`)
9
11
 
10
- const server = http.createServer((req, res) => {
11
- return Odac.Route.request(req, res)
12
- })
12
+ if (cluster.isPrimary) {
13
+ const numCPUs = os.cpus().length
14
+ let isShuttingDown = false
13
15
 
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
- })
16
+ 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\\.`)
19
17
 
20
- server.listen(port)
18
+ // Start session garbage collector (runs every hour, expires after 7 days)
19
+ Odac.Storage.startSessionGC()
20
+
21
+ for (let i = 0; i < numCPUs; i++) {
22
+ cluster.fork()
23
+ }
24
+
25
+ cluster.on('exit', () => {
26
+ // Don't restart workers during shutdown
27
+ if (!isShuttingDown) {
28
+ cluster.fork()
29
+ }
30
+ })
31
+
32
+ // Graceful shutdown handler for primary
33
+ const gracefulShutdown = signal => {
34
+ if (isShuttingDown) return
35
+ isShuttingDown = true
36
+
37
+ console.log(`\n\x1b[33m[Shutdown]\x1b[0m ${signal} received, shutting down gracefully...`)
38
+
39
+ // Disconnect all workers
40
+ for (const id in cluster.workers) {
41
+ cluster.workers[id].send('shutdown')
42
+ cluster.workers[id].disconnect()
43
+ }
44
+
45
+ let workersAlive = Object.keys(cluster.workers).length
46
+
47
+ cluster.on('exit', () => {
48
+ workersAlive--
49
+ if (workersAlive === 0) {
50
+ console.log('\x1b[32m[Shutdown]\x1b[0m All workers stopped.')
51
+ Odac.Storage.close()
52
+ console.log('\x1b[32m[Shutdown]\x1b[0m Storage closed. Goodbye!')
53
+ process.exit(0)
54
+ }
55
+ })
56
+
57
+ // Force exit after 30 seconds
58
+ setTimeout(() => {
59
+ console.error('\x1b[31m[Shutdown]\x1b[0m Timeout! Forcing exit...')
60
+ process.exit(1)
61
+ }, 30000)
62
+ }
63
+
64
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
65
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'))
66
+ } else {
67
+ const server = http.createServer((req, res) => {
68
+ return Odac.Route.request(req, res)
69
+ })
70
+
71
+ server.on('upgrade', (req, socket, head) => {
72
+ const id = nodeCrypto.randomBytes(16).toString('hex')
73
+ const param = Odac.instance(id, req, null)
74
+ Odac.Route.handleWebSocketUpgrade(req, socket, head, param)
75
+ })
76
+
77
+ server.listen(port)
78
+
79
+ // Graceful shutdown handler for worker
80
+ process.on('message', msg => {
81
+ if (msg === 'shutdown') {
82
+ console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Closing server...`)
83
+ server.close(() => {
84
+ console.log(`\x1b[36m[Worker ${process.pid}]\x1b[0m Server closed.`)
85
+ process.exit(0)
86
+ })
87
+ }
88
+ })
89
+ }
21
90
  }
22
91
  }
package/src/Storage.js ADDED
@@ -0,0 +1,165 @@
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
+ })
26
+ this.ready = true
27
+ } catch (error) {
28
+ console.error('\x1b[31m[Storage Error]\x1b[0m Failed to initialize LMDB:', error.message)
29
+ console.error('\x1b[33m[Storage]\x1b[0m Path:', dbPath)
30
+ this.ready = false
31
+ }
32
+ }
33
+
34
+ // --- Basic KV Operations ---
35
+
36
+ get(key) {
37
+ if (!this.ready) return null
38
+ return this.db.get(key) ?? null
39
+ }
40
+
41
+ put(key, value) {
42
+ if (!this.ready) return false
43
+ return this.db.put(key, value)
44
+ }
45
+
46
+ remove(key) {
47
+ if (!this.ready) return false
48
+ return this.db.remove(key)
49
+ }
50
+
51
+ // --- Range Operations ---
52
+
53
+ getRange(options = {}) {
54
+ if (!this.ready) return []
55
+ return this.db.getRange(options)
56
+ }
57
+
58
+ getKeys(options = {}) {
59
+ if (!this.ready) return []
60
+ return this.db.getKeys(options)
61
+ }
62
+
63
+ // --- Session Garbage Collector ---
64
+
65
+ startSessionGC(intervalMs = 60 * 60 * 1000, expirationMs = 7 * 24 * 60 * 60 * 1000) {
66
+ if (!this.ready) {
67
+ console.warn('[Storage] GC not started: Storage not ready')
68
+ return null
69
+ }
70
+
71
+ const BATCH_THRESHOLD = 10000
72
+ const BATCH_SIZE = 1000
73
+
74
+ return setInterval(() => {
75
+ try {
76
+ // Count sessions to decide mode
77
+ let sessionCount = 0
78
+ // eslint-disable-next-line
79
+ for (const _ of this.db.getKeys({start: 'sess:', end: 'sess:~', limit: BATCH_THRESHOLD + 1})) {
80
+ sessionCount++
81
+ if (sessionCount > BATCH_THRESHOLD) break
82
+ }
83
+
84
+ if (sessionCount > BATCH_THRESHOLD) {
85
+ this._cleanSessionsBatch(expirationMs, BATCH_SIZE)
86
+ } else {
87
+ this._cleanSessionsSimple(expirationMs)
88
+ }
89
+ } catch (error) {
90
+ console.error('\x1b[31m[Storage GC Error]\x1b[0m', error.message)
91
+ }
92
+ }, intervalMs)
93
+ }
94
+
95
+ // Simple mode: Load all sessions at once (fast for small datasets)
96
+ _cleanSessionsSimple(expirationMs) {
97
+ const now = Date.now()
98
+ let count = 0
99
+
100
+ for (const {key, value} of this.db.getRange({start: 'sess:', end: 'sess:~', snapshot: false})) {
101
+ if (key.endsWith(':_created') && now - value > expirationMs) {
102
+ const prefix = key.replace(':_created', '')
103
+ for (const subKey of this.db.getKeys({start: prefix, end: prefix + '~'})) {
104
+ this.db.remove(subKey)
105
+ }
106
+ count++
107
+ }
108
+ }
109
+
110
+ if (count > 0) {
111
+ console.log(`\x1b[36m[Storage GC]\x1b[0m Cleaned ${count} expired sessions.`)
112
+ }
113
+ }
114
+
115
+ // Batch mode: Process sessions in chunks (memory-safe for large datasets)
116
+ _cleanSessionsBatch(expirationMs, batchSize) {
117
+ const now = Date.now()
118
+ let count = 0
119
+ let cursor = 'sess:'
120
+ let hasMore = true
121
+
122
+ console.log('\x1b[36m[Storage GC]\x1b[0m Running in batch mode...')
123
+
124
+ while (hasMore) {
125
+ hasMore = false
126
+ let lastKey = null
127
+
128
+ for (const {key, value} of this.db.getRange({start: cursor, end: 'sess:~', limit: batchSize, snapshot: false})) {
129
+ lastKey = key
130
+ hasMore = true
131
+
132
+ if (key.endsWith(':_created') && now - value > expirationMs) {
133
+ const prefix = key.replace(':_created', '')
134
+ for (const subKey of this.db.getKeys({start: prefix, end: prefix + '~'})) {
135
+ this.db.remove(subKey)
136
+ }
137
+ count++
138
+ }
139
+ }
140
+
141
+ if (lastKey) {
142
+ cursor = lastKey + '\0'
143
+ }
144
+ }
145
+
146
+ if (count > 0) {
147
+ console.log(`\x1b[36m[Storage GC]\x1b[0m Cleaned ${count} expired sessions (batch mode).`)
148
+ }
149
+ }
150
+
151
+ // --- Utility ---
152
+
153
+ close() {
154
+ if (this.db) {
155
+ this.db.close()
156
+ this.ready = false
157
+ }
158
+ }
159
+
160
+ isReady() {
161
+ return this.ready
162
+ }
163
+ }
164
+
165
+ module.exports = new OdacStorage()
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
@@ -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'