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/Request.js CHANGED
@@ -3,14 +3,16 @@ const nodeCrypto = require('crypto')
3
3
  class OdacRequest {
4
4
  #odac
5
5
  #complete = false
6
- #cookies = {received: [], sent: []}
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 (Odac.Route.routes[this.route].error && Odac.Route.routes[this.route].error[code])
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.sent[key]) return this.#cookies.sent[key]
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 = 'Strict'
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].split('; ')[1].split('=')[1]
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('md5')
245
+ .createHash('sha256')
235
246
  .update(this.req.headers['user-agent'] ?? '.')
236
247
  .digest('hex')
237
- let pub = this.cookie('candy_session')
238
-
239
- if (!pub || !Odac.Request.session[pub + '-' + pri]) {
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
- if (Odac.Request.sessionLocks[lockKey]) {
244
- const lock = Odac.Request.sessionLocks[lockKey]
245
- if (now - lock.timestamp < 5000 && Odac.Request.session[`${lock.sessionId}-${pri}`]) {
246
- pub = lock.sessionId
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
- delete Odac.Request.sessionLocks[lockKey]
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
- .createHash('md5')
258
- .update(this.ip + this.id + Date.now().toString() + Math.random().toString())
259
- .digest('hex')
260
- } while (Odac.Request.session[`${pub}-${pri}`] || activeSessions.has(pub))
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
- if (Odac.Request.sessionLocks[lockKey]?.timestamp === now) {
268
- delete Odac.Request.sessionLocks[lockKey]
270
+ const lock = Odac.Storage.get(lockKey)
271
+ if (lock?.timestamp === now) {
272
+ Odac.Storage.remove(lockKey)
269
273
  }
270
- }, 5000)
274
+ }, 2000)
271
275
  }
272
276
  }
273
277
 
274
- if (!Odac.Request.session[pub + '-' + pri]) Odac.Request.session[pub + '-' + pri] = {}
275
- if (value === undefined) return Odac.Request.session[pub + '-' + pri][key] ?? null
276
- else if (value === null) delete Odac.Request.session[pub + '-' + pri][key]
277
- else Odac.Request.session[pub + '-' + pri][key] = value
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
- this.#interval = setInterval(this.check.bind(this), 60 * 1000) // Check every minute
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 (job.lastRun && weekDay % condition.value !== 0) shouldRun = false
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
- job.function()
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
- return {
110
- minute: value => this.#jobs[id].condition.push({type: 'minute', value: value}),
111
- hour: value => this.#jobs[id].condition.push({type: 'hour', value: value}),
112
- day: value => this.#jobs[id].condition.push({type: 'day', value: value}),
113
- weekDay: value => this.#jobs[id].condition.push({type: 'weekDay', value: value}),
114
- month: value => this.#jobs[id].condition.push({type: 'month', value: value}),
115
- year: value => this.#jobs[id].condition.push({type: 'year', value: value}),
116
- yearDay: value => this.#jobs[id].condition.push({type: 'yearDay', value: value}),
117
- everyMinute: value => this.#jobs[id].condition.push({type: 'everyMinute', value: value}),
118
- everyHour: value => this.#jobs[id].condition.push({type: 'everyHour', value: value}),
119
- everyDay: value => this.#jobs[id].condition.push({type: 'everyDay', value: value}),
120
- everyWeekDay: value => this.#jobs[id].condition.push({type: 'everyWeekDay', value: value}),
121
- everyMonth: value => this.#jobs[id].condition.push({type: 'everyMonth', value: value}),
122
- everyYear: value => this.#jobs[id].condition.push({type: 'everyYear', value: value}),
123
- everyYearDay: value => this.#jobs[id].condition.push({type: 'everyYearDay', value: value})
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
 
@@ -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 not configured') {
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 not configured') {
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 mysql = Odac.Mysql
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 mysql.query(`SELECT id FROM ?? WHERE ?? = ? LIMIT 1`, [
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 && existingRecord.length > 0) {
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 mysql.query('INSERT INTO ?? SET ?', [Odac.formConfig.table, Odac.formData])
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 not configured') {
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 config.json'}
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
@@ -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 = []