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/Request.js
CHANGED
|
@@ -3,14 +3,16 @@ const nodeCrypto = require('crypto')
|
|
|
3
3
|
class OdacRequest {
|
|
4
4
|
#odac
|
|
5
5
|
#complete = false
|
|
6
|
-
#cookies = {
|
|
6
|
+
#cookies = {data: {}, sent: []}
|
|
7
7
|
data = {post: {}, get: {}, url: {}}
|
|
8
8
|
#event = {data: [], end: []}
|
|
9
9
|
#headers = {Server: 'Odac'}
|
|
10
10
|
#status = 200
|
|
11
11
|
#timeout = null
|
|
12
12
|
#earlyHints = null
|
|
13
|
+
#sessions = {}
|
|
13
14
|
variables = {}
|
|
15
|
+
sharedData = {}
|
|
14
16
|
isAjaxLoad = false
|
|
15
17
|
ajaxLoad = null
|
|
16
18
|
clientSkeleton = null
|
|
@@ -26,6 +28,7 @@ class OdacRequest {
|
|
|
26
28
|
this.host = req.headers.host
|
|
27
29
|
this.ssl = this.header('x-odac-connection-ssl') === 'true'
|
|
28
30
|
this.ip = (this.header('x-odac-connection-remoteaddress') ?? req.connection.remoteAddress).replace('::ffff:', '')
|
|
31
|
+
this.language = req.headers['accept-language']?.split(',')[0] ?? 'en'
|
|
29
32
|
delete this.req.headers['x-odac-connection-ssl']
|
|
30
33
|
delete this.req.headers['x-odac-connection-remoteaddress']
|
|
31
34
|
let route = req.headers.host.split('.')[0]
|
|
@@ -36,21 +39,17 @@ class OdacRequest {
|
|
|
36
39
|
}
|
|
37
40
|
this.#data()
|
|
38
41
|
if (!Odac.Request) Odac.Request = {}
|
|
39
|
-
if (!this.cookie('odac_client') || !this.session('_client') || this.session('_client') !== this.cookie('odac_client')) {
|
|
40
|
-
let client = nodeCrypto
|
|
41
|
-
.createHash('md5')
|
|
42
|
-
.update(this.ip + this.id + Date.now().toString() + Math.random().toString())
|
|
43
|
-
.digest('hex')
|
|
44
|
-
this.cookie('odac_client', client, {expires: null, httpOnly: false})
|
|
45
|
-
this.session('_client', client)
|
|
46
|
-
}
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
// - ABORT REQUEST
|
|
50
45
|
async abort(code) {
|
|
51
46
|
this.status(code)
|
|
52
47
|
let result = {401: 'Unauthorized', 404: 'Not Found', 408: 'Request Timeout'}[code] ?? null
|
|
53
|
-
if (
|
|
48
|
+
if (
|
|
49
|
+
Odac.Route.routes[this.route].error &&
|
|
50
|
+
Odac.Route.routes[this.route].error[code] &&
|
|
51
|
+
typeof Odac.Route.routes[this.route].error[code].cache === 'function'
|
|
52
|
+
)
|
|
54
53
|
result = await Odac.Route.routes[this.route].error[code].cache(this.#odac)
|
|
55
54
|
this.end(result)
|
|
56
55
|
}
|
|
@@ -58,22 +57,21 @@ class OdacRequest {
|
|
|
58
57
|
// - SET COOKIE
|
|
59
58
|
cookie(key, value, options = {}) {
|
|
60
59
|
if (value === undefined) {
|
|
61
|
-
if (this.#cookies.
|
|
62
|
-
if (this.#cookies.received[key]) return this.#cookies.received[key]
|
|
60
|
+
if (this.#cookies.data[key]) return this.#cookies.data[key]
|
|
63
61
|
value =
|
|
64
62
|
this.req.headers.cookie
|
|
65
63
|
?.split('; ')
|
|
66
64
|
.find(c => c.startsWith(key + '='))
|
|
67
65
|
?.split('=')[1] ?? null
|
|
68
66
|
if (value && value.startsWith('{') && value.endsWith('}')) value = JSON.parse(value)
|
|
69
|
-
this.#cookies.received[key] = value
|
|
70
67
|
return value
|
|
71
68
|
}
|
|
69
|
+
this.#cookies.data[key] = value
|
|
72
70
|
if (options.path === undefined) options.path = '/'
|
|
73
71
|
if (options.expires === undefined) options.expires = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365).toUTCString()
|
|
74
72
|
if (options.secure === undefined) options.secure = true
|
|
75
73
|
if (options.httpOnly === undefined) options.httpOnly = true
|
|
76
|
-
if (options.sameSite === undefined) options.sameSite = '
|
|
74
|
+
if (options.sameSite === undefined) options.sameSite = 'Lax'
|
|
77
75
|
if (typeof value === 'object') value = JSON.stringify(value)
|
|
78
76
|
let cookie = `${key}=${value}`
|
|
79
77
|
for (const option of Object.keys(options)) if (options[option]) cookie += `; ${option}=${options[option]}`
|
|
@@ -86,8 +84,8 @@ class OdacRequest {
|
|
|
86
84
|
let data = split[1].split('&')
|
|
87
85
|
for (let i = 0; i < data.length; i++) {
|
|
88
86
|
if (data[i].indexOf('=') === -1) continue
|
|
89
|
-
let key = data[i].split('=')[0]
|
|
90
|
-
let val = data[i].split('=')[1]
|
|
87
|
+
let key = decodeURIComponent(data[i].split('=')[0])
|
|
88
|
+
let val = decodeURIComponent(data[i].split('=')[1] || '')
|
|
91
89
|
this.data.get[key] = val
|
|
92
90
|
}
|
|
93
91
|
}
|
|
@@ -101,7 +99,14 @@ class OdacRequest {
|
|
|
101
99
|
} else {
|
|
102
100
|
if (body.length > 0 && body.indexOf('Content-Disposition') === -1) return
|
|
103
101
|
if (body.indexOf('Content-Disposition') > -1) {
|
|
104
|
-
let boundary = body.split('\r\n')[0]
|
|
102
|
+
let boundary = body.split('\r\n')[0]
|
|
103
|
+
if (boundary.includes('boundary=')) {
|
|
104
|
+
try {
|
|
105
|
+
boundary = boundary.split('boundary=')[1].split(';')[0].trim()
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore
|
|
108
|
+
}
|
|
109
|
+
}
|
|
105
110
|
let data = body.split(boundary)
|
|
106
111
|
for (let i = 0; i < data.length; i++) {
|
|
107
112
|
if (data[i].indexOf('Content-Disposition') === -1) continue
|
|
@@ -203,6 +208,7 @@ class OdacRequest {
|
|
|
203
208
|
redirect(url) {
|
|
204
209
|
this.header('Location', url)
|
|
205
210
|
this.status(302)
|
|
211
|
+
this.end()
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
// - GET REQUEST
|
|
@@ -225,56 +231,63 @@ class OdacRequest {
|
|
|
225
231
|
})
|
|
226
232
|
}
|
|
227
233
|
|
|
234
|
+
setSession() {
|
|
235
|
+
if (!this.cookie('odac_client') || !this.session('_client') || this.session('_client') !== this.cookie('odac_client')) {
|
|
236
|
+
let client = nodeCrypto.randomBytes(16).toString('hex')
|
|
237
|
+
this.cookie('odac_client', client, {expires: null, httpOnly: false})
|
|
238
|
+
this.session('_client', client)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
// - SESSION
|
|
229
243
|
session(key, value) {
|
|
230
|
-
if (!Odac.Request.session) Odac.Request.session = {}
|
|
231
|
-
if (!Odac.Request.sessionLocks) Odac.Request.sessionLocks = {}
|
|
232
|
-
|
|
233
244
|
let pri = nodeCrypto
|
|
234
|
-
.createHash('
|
|
245
|
+
.createHash('sha256')
|
|
235
246
|
.update(this.req.headers['user-agent'] ?? '.')
|
|
236
247
|
.digest('hex')
|
|
237
|
-
let pub = this.cookie('
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const lockKey = `${this.ip}-${pri}`
|
|
248
|
+
let pub = this.cookie('odac_session')
|
|
249
|
+
if (!pub || !Odac.Storage.get(`sess:${pub}:${pri}:_created`)) {
|
|
250
|
+
const lockKey = `lock:${this.ip}:${pri}`
|
|
241
251
|
const now = Date.now()
|
|
242
252
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (now -
|
|
246
|
-
pub =
|
|
253
|
+
const existingLock = Odac.Storage.get(lockKey)
|
|
254
|
+
if (existingLock) {
|
|
255
|
+
if (now - existingLock.timestamp < 2000 && Odac.Storage.get(`sess:${existingLock.sessionId}:${pri}:_created`)) {
|
|
256
|
+
pub = existingLock.sessionId
|
|
247
257
|
} else {
|
|
248
|
-
|
|
258
|
+
Odac.Storage.remove(lockKey)
|
|
249
259
|
}
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
if (!pub) {
|
|
253
|
-
const sessionLockValues = Object.values(Odac.Request.sessionLocks)
|
|
254
|
-
const activeSessions = new Set(sessionLockValues.map(l => l.sessionId))
|
|
255
263
|
do {
|
|
256
|
-
pub = nodeCrypto
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
Odac.Request.sessionLocks[lockKey] = {sessionId: pub, timestamp: now}
|
|
263
|
-
Odac.Request.session[`${pub}-${pri}`] = {}
|
|
264
|
-
this.cookie('candy_session', `${pub}`)
|
|
265
|
-
|
|
264
|
+
pub = nodeCrypto.randomBytes(16).toString('hex')
|
|
265
|
+
} while (Odac.Storage.get(`sess:${pub}:${pri}:_created`))
|
|
266
|
+
Odac.Storage.put(lockKey, {sessionId: pub, timestamp: now})
|
|
267
|
+
Odac.Storage.put(`sess:${pub}:${pri}:_created`, now)
|
|
268
|
+
this.cookie('odac_session', `${pub}`)
|
|
266
269
|
setTimeout(() => {
|
|
267
|
-
|
|
268
|
-
|
|
270
|
+
const lock = Odac.Storage.get(lockKey)
|
|
271
|
+
if (lock?.timestamp === now) {
|
|
272
|
+
Odac.Storage.remove(lockKey)
|
|
269
273
|
}
|
|
270
|
-
},
|
|
274
|
+
}, 2000)
|
|
271
275
|
}
|
|
272
276
|
}
|
|
273
277
|
|
|
274
|
-
|
|
275
|
-
if (value === undefined)
|
|
276
|
-
|
|
277
|
-
|
|
278
|
+
const dbKey = `sess:${pub}:${pri}:${key}`
|
|
279
|
+
if (value === undefined) {
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(this.#sessions, dbKey)) return this.#sessions[dbKey]
|
|
281
|
+
const dbValue = Odac.Storage.get(dbKey) ?? null
|
|
282
|
+
return dbValue
|
|
283
|
+
} else if (value === null) {
|
|
284
|
+
delete this.#sessions[dbKey]
|
|
285
|
+
delete this.#sessions[dbKey]
|
|
286
|
+
Odac.Storage.remove(dbKey)
|
|
287
|
+
} else {
|
|
288
|
+
this.#sessions[dbKey] = value
|
|
289
|
+
Odac.Storage.put(dbKey, value)
|
|
290
|
+
}
|
|
278
291
|
}
|
|
279
292
|
|
|
280
293
|
// - SET
|
|
@@ -283,6 +296,15 @@ class OdacRequest {
|
|
|
283
296
|
else this.variables[key] = {value: value, ajax: ajax}
|
|
284
297
|
}
|
|
285
298
|
|
|
299
|
+
// - SHARE DATA (Client Side)
|
|
300
|
+
share(key, value) {
|
|
301
|
+
if (typeof key === 'object' && key !== null) {
|
|
302
|
+
Object.assign(this.sharedData, key)
|
|
303
|
+
} else {
|
|
304
|
+
this.sharedData[key] = value
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
286
308
|
// - HTTP CODE
|
|
287
309
|
status(code) {
|
|
288
310
|
this.#status = code
|
package/src/Route/Cron.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
|
+
const cluster = require('node:cluster')
|
|
2
3
|
|
|
3
4
|
class Cron {
|
|
4
5
|
#interval = null
|
|
@@ -6,7 +7,9 @@ class Cron {
|
|
|
6
7
|
|
|
7
8
|
init() {
|
|
8
9
|
if (this.#interval) return
|
|
9
|
-
|
|
10
|
+
if (cluster.isPrimary) {
|
|
11
|
+
this.#interval = setInterval(this.check.bind(this), 60 * 1000) // Check every minute
|
|
12
|
+
}
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
check() {
|
|
@@ -57,7 +60,7 @@ class Cron {
|
|
|
57
60
|
if (job.lastRun && Math.floor(unix / 86400) % condition.value !== 0) shouldRun = false
|
|
58
61
|
break
|
|
59
62
|
case 'everyWeekDay':
|
|
60
|
-
if (
|
|
63
|
+
if (condition.value !== weekDay) shouldRun = false
|
|
61
64
|
break
|
|
62
65
|
case 'everyMonth':
|
|
63
66
|
if (job.lastRun && (year * 12 + month) % condition.value !== 0) shouldRun = false
|
|
@@ -78,7 +81,9 @@ class Cron {
|
|
|
78
81
|
if (job.function || fs.existsSync(job.path)) {
|
|
79
82
|
if (!job.function) job.function = require(job.path)
|
|
80
83
|
if (job.function && typeof job.function === 'function') {
|
|
81
|
-
|
|
84
|
+
const _odac = global.Odac.instance(null, 'cron')
|
|
85
|
+
job.function(_odac)
|
|
86
|
+
if (_odac.cleanup) _odac.cleanup()
|
|
82
87
|
}
|
|
83
88
|
}
|
|
84
89
|
} catch (error) {
|
|
@@ -106,22 +111,61 @@ class Cron {
|
|
|
106
111
|
updated: new Date()
|
|
107
112
|
})
|
|
108
113
|
let id = this.#jobs.length - 1
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
114
|
+
const addCondition = (type, value) => {
|
|
115
|
+
this.#jobs[id].condition.push({type, value})
|
|
116
|
+
return chain
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const chain = {
|
|
120
|
+
minute: value => addCondition('minute', value),
|
|
121
|
+
hour: value => addCondition('hour', value),
|
|
122
|
+
day: value => addCondition('day', value),
|
|
123
|
+
at: time => {
|
|
124
|
+
if (!/^\d{1,2}:\d{1,2}$/.test(time)) throw new Error('Invalid time format for .at(). Use HH:MM')
|
|
125
|
+
const [h, m] = time.split(':')
|
|
126
|
+
addCondition('hour', parseInt(h))
|
|
127
|
+
addCondition('minute', parseInt(m))
|
|
128
|
+
return chain
|
|
129
|
+
},
|
|
130
|
+
raw: pattern => {
|
|
131
|
+
const parts = pattern.split(' ').filter(p => p.trim() !== '')
|
|
132
|
+
if (parts.length !== 5) throw new Error('Invalid cron expression. Expected 5 fields (min hour day month weekDay)')
|
|
133
|
+
|
|
134
|
+
const [min, hour, day, month, weekDay] = parts
|
|
135
|
+
const parse = (val, type, everyType) => {
|
|
136
|
+
if (val === '*') return
|
|
137
|
+
if (val.startsWith('*/') && everyType) {
|
|
138
|
+
addCondition(everyType, parseInt(val.split('/')[1]))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (!isNaN(val)) {
|
|
142
|
+
addCondition(type, parseInt(val))
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`Unsupported cron value '${val}' for ${type}`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
parse(min, 'minute', 'everyMinute')
|
|
149
|
+
parse(hour, 'hour', 'everyHour')
|
|
150
|
+
parse(day, 'day', 'everyDay')
|
|
151
|
+
parse(month, 'month', 'everyMonth')
|
|
152
|
+
parse(weekDay, 'weekDay', null)
|
|
153
|
+
|
|
154
|
+
return chain
|
|
155
|
+
},
|
|
156
|
+
weekDay: value => addCondition('weekDay', value),
|
|
157
|
+
month: value => addCondition('month', value),
|
|
158
|
+
year: value => addCondition('year', value),
|
|
159
|
+
yearDay: value => addCondition('yearDay', value),
|
|
160
|
+
everyMinute: value => addCondition('everyMinute', value),
|
|
161
|
+
everyHour: value => addCondition('everyHour', value),
|
|
162
|
+
everyDay: value => addCondition('everyDay', value),
|
|
163
|
+
everyWeekDay: value => addCondition('everyWeekDay', value),
|
|
164
|
+
everyMonth: value => addCondition('everyMonth', value),
|
|
165
|
+
everyYear: value => addCondition('everyYear', value),
|
|
166
|
+
everyYearDay: value => addCondition('everyYearDay', value)
|
|
124
167
|
}
|
|
168
|
+
return chain
|
|
125
169
|
}
|
|
126
170
|
}
|
|
127
171
|
|
package/src/Route/Internal.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const nodeCrypto = require('crypto')
|
|
2
|
+
|
|
1
3
|
class Internal {
|
|
2
4
|
static #validateField(validator, field, validation, value) {
|
|
3
5
|
const rules = validation.rule.split('|')
|
|
@@ -107,7 +109,7 @@ class Internal {
|
|
|
107
109
|
})
|
|
108
110
|
|
|
109
111
|
if (!registerResult.success) {
|
|
110
|
-
if (registerResult.error === 'Database connection
|
|
112
|
+
if (registerResult.error === 'Database connection failed') {
|
|
111
113
|
return Odac.return({
|
|
112
114
|
result: {success: false},
|
|
113
115
|
errors: {_odac_form: 'Service temporarily unavailable. Please try again later.'}
|
|
@@ -247,7 +249,7 @@ class Internal {
|
|
|
247
249
|
const loginResult = await Odac.Auth.login(credentials)
|
|
248
250
|
|
|
249
251
|
if (!loginResult.success) {
|
|
250
|
-
if (loginResult.error === 'Database connection
|
|
252
|
+
if (loginResult.error === 'Database connection failed') {
|
|
251
253
|
return Odac.return({
|
|
252
254
|
result: {success: false},
|
|
253
255
|
errors: {_odac_form: 'Service temporarily unavailable. Please try again later.'}
|
|
@@ -272,6 +274,117 @@ class Internal {
|
|
|
272
274
|
})
|
|
273
275
|
}
|
|
274
276
|
|
|
277
|
+
static async magicLogin(Odac) {
|
|
278
|
+
const token = await Odac.request('_odac_magic_login_token')
|
|
279
|
+
if (!token) {
|
|
280
|
+
return Odac.return({
|
|
281
|
+
result: {success: false},
|
|
282
|
+
errors: {_odac_form: 'Invalid request'}
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const formData = Odac.Request.session(`_magic_login_form_${token}`)
|
|
287
|
+
if (!formData) {
|
|
288
|
+
return Odac.return({
|
|
289
|
+
result: {success: false},
|
|
290
|
+
errors: {_odac_form: 'Form session expired. Please refresh the page.'}
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (formData.expires < Date.now()) {
|
|
295
|
+
Odac.Request.session(`_magic_login_form_${token}`, null)
|
|
296
|
+
return Odac.return({
|
|
297
|
+
result: {success: false},
|
|
298
|
+
errors: {_odac_form: 'Form session expired. Please refresh the page.'}
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Basic security checks
|
|
303
|
+
if (
|
|
304
|
+
formData.sessionId !== Odac.Request.session('_client') ||
|
|
305
|
+
formData.userAgent !== Odac.Request.header('user-agent') ||
|
|
306
|
+
formData.ip !== Odac.Request.ip
|
|
307
|
+
) {
|
|
308
|
+
return Odac.return({
|
|
309
|
+
result: {success: false},
|
|
310
|
+
errors: {_odac_form: 'Invalid request security token'}
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const config = formData.config
|
|
315
|
+
const validator = Odac.validator()
|
|
316
|
+
let email = ''
|
|
317
|
+
|
|
318
|
+
// Validate inputs (expecting email)
|
|
319
|
+
for (const field of config.fields) {
|
|
320
|
+
const value = await Odac.request(field.name)
|
|
321
|
+
for (const validation of field.validations) {
|
|
322
|
+
this.#validateField(validator, field, validation, value)
|
|
323
|
+
}
|
|
324
|
+
if (field.name === 'email') email = value
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (await validator.error()) {
|
|
328
|
+
return validator.result()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!email) {
|
|
332
|
+
return Odac.return({
|
|
333
|
+
result: {success: false},
|
|
334
|
+
errors: {email: 'Email is required'}
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const result = await Odac.Auth.magic(email, {
|
|
339
|
+
autoRegister: false, // config.autoRegister
|
|
340
|
+
redirect: config.redirect
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
if (!result.success) {
|
|
344
|
+
return Odac.return({
|
|
345
|
+
result: {success: false},
|
|
346
|
+
errors: {_odac_form: result.error || 'Failed to send magic link'}
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
Odac.Request.session(`_magic_login_form_${token}`, null)
|
|
351
|
+
|
|
352
|
+
return Odac.return({
|
|
353
|
+
result: {
|
|
354
|
+
success: true,
|
|
355
|
+
message: result.message || 'Magic link sent! Please check your email.',
|
|
356
|
+
// We might want to keep them on page or redirect to a "check email" page
|
|
357
|
+
redirect: config.redirect
|
|
358
|
+
}
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
static async magicVerify(Odac) {
|
|
363
|
+
const token = await Odac.request('token')
|
|
364
|
+
const email = await Odac.request('email')
|
|
365
|
+
|
|
366
|
+
if (!token || !email) {
|
|
367
|
+
return Odac.Request.end('Invalid verification link.')
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const result = await Odac.Auth.verifyMagicLink(token, email)
|
|
371
|
+
|
|
372
|
+
if (!result.success) {
|
|
373
|
+
return Odac.Request.end(`Verification failed: ${result.error}`)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Redirect to a specific URL if provided, otherwise default to home or a configured dashboard page.
|
|
377
|
+
let redirectUrl = (await Odac.request('redirect_url')) || Odac.Config.auth?.magicLinkRedirect || '/'
|
|
378
|
+
|
|
379
|
+
// Security: Prevent open redirect attacks by only allowing relative paths
|
|
380
|
+
if (redirectUrl && (!redirectUrl.startsWith('/') || redirectUrl.startsWith('//'))) {
|
|
381
|
+
redirectUrl = '/'
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
Odac.Request.redirect(redirectUrl)
|
|
385
|
+
Odac.Request.end('')
|
|
386
|
+
}
|
|
387
|
+
|
|
275
388
|
static async processForm(Odac) {
|
|
276
389
|
const token = await Odac.request('_odac_form_token')
|
|
277
390
|
if (!token) return
|
|
@@ -384,18 +497,14 @@ class Internal {
|
|
|
384
497
|
|
|
385
498
|
if (Odac.formConfig.table) {
|
|
386
499
|
try {
|
|
387
|
-
const
|
|
500
|
+
const table = Odac.DB[Odac.formConfig.table]
|
|
388
501
|
|
|
389
502
|
for (const field of Odac.formUniqueFields) {
|
|
390
503
|
if (Odac.formData[field.name] == null) continue
|
|
391
504
|
|
|
392
|
-
const existingRecord = await
|
|
393
|
-
Odac.formConfig.table,
|
|
394
|
-
field.name,
|
|
395
|
-
Odac.formData[field.name]
|
|
396
|
-
])
|
|
505
|
+
const existingRecord = await table.where(field.name, Odac.formData[field.name]).first()
|
|
397
506
|
|
|
398
|
-
if (existingRecord
|
|
507
|
+
if (existingRecord) {
|
|
399
508
|
const errorMessage = field.message || `This ${field.name} is already registered`
|
|
400
509
|
return Odac.return({
|
|
401
510
|
result: {success: false},
|
|
@@ -404,7 +513,7 @@ class Internal {
|
|
|
404
513
|
}
|
|
405
514
|
}
|
|
406
515
|
|
|
407
|
-
await
|
|
516
|
+
await table.insert(Odac.formData)
|
|
408
517
|
|
|
409
518
|
Odac.Request.session(`_custom_form_${token}`, null)
|
|
410
519
|
|
|
@@ -416,10 +525,10 @@ class Internal {
|
|
|
416
525
|
}
|
|
417
526
|
})
|
|
418
527
|
} catch (error) {
|
|
419
|
-
if (error.message === 'Database connection
|
|
528
|
+
if (error.message === 'Database connection failed') {
|
|
420
529
|
return Odac.return({
|
|
421
530
|
result: {success: false},
|
|
422
|
-
errors: {_odac_form: 'Database not configured. Please check your
|
|
531
|
+
errors: {_odac_form: 'Database not configured. Please check your odac.json'}
|
|
423
532
|
})
|
|
424
533
|
}
|
|
425
534
|
|
|
@@ -430,6 +539,100 @@ class Internal {
|
|
|
430
539
|
}
|
|
431
540
|
}
|
|
432
541
|
|
|
542
|
+
if (Odac.formConfig.action) {
|
|
543
|
+
const actionParts = Odac.formConfig.action.split('.')
|
|
544
|
+
if (actionParts.length === 2) {
|
|
545
|
+
const controllerName = actionParts[0]
|
|
546
|
+
const methodName = actionParts[1]
|
|
547
|
+
|
|
548
|
+
// Dynamically load controller
|
|
549
|
+
// We need to access Odac.Route.class to find the controller path/module
|
|
550
|
+
// Or use require directly if we know the path structure.
|
|
551
|
+
// Since we are in framework/src/Route/Internal.js, controllers are in framework/controller/ OR app/controller/
|
|
552
|
+
// Ideally Odac.Route.class has the loaded controllers.
|
|
553
|
+
|
|
554
|
+
let controllerModule = null
|
|
555
|
+
|
|
556
|
+
if (Odac.Route && Odac.Route.class && Odac.Route.class[controllerName]) {
|
|
557
|
+
controllerModule = Odac.Route.class[controllerName].module
|
|
558
|
+
} else {
|
|
559
|
+
// Try to require it if not loaded (though Route.js should have loaded it)
|
|
560
|
+
// This fallback might be tricky with absolute paths, relying on Route.class is safer.
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (controllerModule) {
|
|
564
|
+
try {
|
|
565
|
+
// Create Form Helper Object
|
|
566
|
+
const formHelper = {
|
|
567
|
+
data: Odac.formData,
|
|
568
|
+
|
|
569
|
+
error: (field, message) => {
|
|
570
|
+
return Odac.return({
|
|
571
|
+
result: {success: false},
|
|
572
|
+
errors: {[field]: message}
|
|
573
|
+
})
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
success: (message, redirect = null) => {
|
|
577
|
+
const finalRedirect = redirect || Odac.formConfig.redirect
|
|
578
|
+
let newToken = null
|
|
579
|
+
|
|
580
|
+
// Only rotate token if we are staying on the page (no redirect)
|
|
581
|
+
if (!finalRedirect) {
|
|
582
|
+
newToken = nodeCrypto.randomBytes(32).toString('hex')
|
|
583
|
+
|
|
584
|
+
if (formData && formData.config) {
|
|
585
|
+
const newFormData = {
|
|
586
|
+
...formData,
|
|
587
|
+
config: {...formData.config, token: newToken},
|
|
588
|
+
created: Date.now(),
|
|
589
|
+
expires: Date.now() + 30 * 60 * 1000
|
|
590
|
+
}
|
|
591
|
+
Odac.Request.session(`_custom_form_${newToken}`, newFormData)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
Odac.Request.session(`_custom_form_${token}`, null)
|
|
596
|
+
|
|
597
|
+
return Odac.return({
|
|
598
|
+
result: {
|
|
599
|
+
success: true,
|
|
600
|
+
message: message,
|
|
601
|
+
redirect: finalRedirect,
|
|
602
|
+
_token: newToken
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Handle Class-based Controller
|
|
609
|
+
if (typeof controllerModule === 'function' && controllerModule.prototype) {
|
|
610
|
+
const instance = new controllerModule(Odac)
|
|
611
|
+
if (typeof instance[methodName] === 'function') {
|
|
612
|
+
return await instance[methodName](formHelper)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// Handle Object-based Controller (Backwards Compatibility)
|
|
616
|
+
else if (typeof controllerModule[methodName] === 'function') {
|
|
617
|
+
return await controllerModule[methodName](Odac, formHelper)
|
|
618
|
+
}
|
|
619
|
+
} catch (e) {
|
|
620
|
+
console.error(e)
|
|
621
|
+
return Odac.return({
|
|
622
|
+
result: {success: false},
|
|
623
|
+
errors: {_odac_form: 'An error occurred while processing your request.'}
|
|
624
|
+
})
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
console.error(`Action ${Odac.formConfig.action} not found or invalid.`)
|
|
629
|
+
return Odac.return({
|
|
630
|
+
result: {success: false},
|
|
631
|
+
errors: {_odac_form: 'An error occurred while processing your request.'}
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
433
636
|
Odac.Request.session(`_custom_form_${token}`, null)
|
|
434
637
|
|
|
435
638
|
return null
|
package/src/Route/Middleware.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
class MiddlewareChain {
|
|
2
|
-
constructor(route, middlewares) {
|
|
2
|
+
constructor(route, middlewares, isAuth = false) {
|
|
3
3
|
this._route = route
|
|
4
4
|
this._middlewares = middlewares
|
|
5
|
+
this._isAuth = isAuth
|
|
5
6
|
this.auth = {
|
|
6
7
|
page: (path, authFile, file) => this.authPage(path, authFile, file),
|
|
7
8
|
post: (path, authFile, file) => this.authPost(path, authFile, file),
|
|
@@ -15,7 +16,8 @@ class MiddlewareChain {
|
|
|
15
16
|
return this
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
page(path, file) {
|
|
19
|
+
page(path, file, fallback) {
|
|
20
|
+
if (this._isAuth) return this.authPage(path, file, fallback)
|
|
19
21
|
this._route._pendingMiddlewares = [...this._middlewares]
|
|
20
22
|
this._route.page(path, file)
|
|
21
23
|
this._route._pendingMiddlewares = []
|
|
@@ -23,6 +25,7 @@ class MiddlewareChain {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
post(path, file, options) {
|
|
28
|
+
if (this._isAuth) return this.authPost(path, file, options)
|
|
26
29
|
this._route._pendingMiddlewares = [...this._middlewares]
|
|
27
30
|
this._route.post(path, file, options)
|
|
28
31
|
this._route._pendingMiddlewares = []
|
|
@@ -30,6 +33,7 @@ class MiddlewareChain {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
get(path, file, options) {
|
|
36
|
+
if (this._isAuth) return this.authGet(path, file, options)
|
|
33
37
|
this._route._pendingMiddlewares = [...this._middlewares]
|
|
34
38
|
this._route.get(path, file, options)
|
|
35
39
|
this._route._pendingMiddlewares = []
|
|
@@ -37,6 +41,7 @@ class MiddlewareChain {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
ws(path, handler, options) {
|
|
44
|
+
if (this._isAuth) return this.authWs(path, handler, options)
|
|
40
45
|
this._route._pendingMiddlewares = [...this._middlewares]
|
|
41
46
|
this._route.ws(path, handler, options)
|
|
42
47
|
this._route._pendingMiddlewares = []
|