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.
- package/.github/workflows/auto-pr-description.yml +3 -1
- package/CHANGELOG.md +127 -0
- package/README.md +39 -36
- package/bin/odac.js +1 -31
- package/client/odac.js +871 -994
- package/docs/backend/01-overview/03-development-server.md +7 -7
- package/docs/backend/02-structure/01-typical-project-layout.md +1 -0
- package/docs/backend/03-config/00-configuration-overview.md +9 -0
- package/docs/backend/03-config/01-database-connection.md +1 -1
- package/docs/backend/04-routing/02-controller-less-view-routes.md +9 -3
- package/docs/backend/04-routing/09-websocket.md +29 -0
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +2 -0
- package/docs/backend/05-controllers/03-controller-classes.md +27 -41
- 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/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/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 +8 -1
- package/docs/backend/10-authentication/04-odac-register-forms.md +46 -46
- package/docs/backend/10-authentication/05-session-management.md +1 -1
- package/docs/backend/10-authentication/06-odac-login-forms.md +48 -48
- 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 +16 -124
- package/eslint.config.mjs +5 -47
- package/package.json +9 -4
- package/src/Auth.js +362 -104
- package/src/Config.js +7 -2
- package/src/Database.js +188 -0
- package/src/Ipc.js +330 -0
- package/src/Mail.js +408 -37
- package/src/Odac.js +65 -9
- package/src/Request.js +70 -48
- package/src/Route/Cron.js +4 -1
- package/src/Route/Internal.js +214 -11
- package/src/Route/Middleware.js +7 -2
- package/src/Route.js +106 -26
- package/src/Server.js +80 -11
- package/src/Storage.js +165 -0
- package/src/Validator.js +94 -2
- package/src/View/Form.js +193 -17
- package/src/View.js +46 -1
- package/src/WebSocket.js +18 -3
- package/template/config.json +1 -1
- package/template/route/www.js +12 -10
- package/test/core/{Candy.test.js → Odac.test.js} +2 -2
- 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/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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (Odac.Route.class
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
if (cluster.isPrimary) {
|
|
13
|
+
const numCPUs = os.cpus().length
|
|
14
|
+
let isShuttingDown = false
|
|
13
15
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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'
|