odac 1.4.14 → 1.4.16
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/CHANGELOG.md +30 -0
- package/bin/odac.js +1 -0
- package/client/odac.js +57 -1
- package/docs/ai/skills/backend/forms.md +54 -3
- package/docs/ai/skills/backend/validation.md +30 -2
- package/docs/ai/skills/frontend/core.md +5 -5
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +1 -1
- package/docs/backend/05-forms/01-custom-forms.md +96 -0
- package/docs/backend/09-validation/01-the-validator-service.md +40 -0
- package/docs/frontend/01-overview/01-introduction.md +8 -8
- package/docs/frontend/02-ajax-navigation/01-quick-start.md +14 -14
- package/docs/frontend/02-ajax-navigation/02-configuration.md +4 -4
- package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +16 -16
- package/docs/frontend/03-forms/01-form-handling.md +84 -31
- package/docs/frontend/04-api-requests/01-get-post.md +25 -25
- package/docs/frontend/05-streaming/01-client-streaming.md +14 -14
- package/docs/frontend/06-websocket/00-overview.md +1 -1
- package/index.js +11 -1
- package/package.json +2 -1
- package/src/Auth.js +252 -24
- package/src/Config.js +5 -1
- package/src/Odac.js +3 -0
- package/src/Request.js +235 -34
- package/src/Route/Internal.js +32 -3
- package/src/Validator.js +143 -2
- package/src/View/Form.js +56 -0
- package/src/View/Image.js +15 -6
- package/src/View.js +2 -2
- package/test/Auth/check.test.js +219 -18
- package/test/Auth/verifyMagicLink.test.js +8 -1
- package/test/Odac/cache.test.js +5 -2
- package/test/Odac/image.test.js +8 -5
- package/test/Odac/instance.test.js +5 -2
- package/test/Request/cache.test.js +1 -0
- package/test/Request/multipart.test.js +164 -0
- package/test/Validator/file.test.js +172 -0
- package/test/View/Form/generateFieldHtml.test.js +56 -0
- package/test/View/Image/serve.test.js +9 -4
- package/test/View/Image/url.test.js +10 -4
- package/test/View/addNavigateAttribute.test.js +10 -1
- package/test/View/print.test.js +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
### ✨ What's New
|
|
2
|
+
|
|
3
|
+
- add multipart file upload support with validation
|
|
4
|
+
|
|
5
|
+
### 🛠️ Fixes & Improvements
|
|
6
|
+
|
|
7
|
+
- **auth:** enhance token validation and anomaly detection mechanisms
|
|
8
|
+
- patch file descriptor leak, polling anti-pattern, mimetype guard and duplicate move check
|
|
9
|
+
- **test:** improve temp file cleanup logic in multipart tests for reliability
|
|
10
|
+
- update cache directory handling to use global.__dir for consistency
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
17
|
+
|
|
18
|
+
### 🛠️ Fixes & Improvements
|
|
19
|
+
|
|
20
|
+
- **docs:** update references from 'odac' to 'Odac' for consistency
|
|
21
|
+
- **form:** improve form submission handler ownership to prevent duplicate submissions
|
|
22
|
+
- **form:** prevent multiple submit handlers on re-bound forms
|
|
23
|
+
- **startup:** surface init/migration errors instead of swallowing them
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
Powered by [⚡ ODAC](https://odac.run)
|
|
30
|
+
|
|
1
31
|
### 🛠️ Fixes & Improvements
|
|
2
32
|
|
|
3
33
|
- **docs:** update frontend scripts documentation to include code obfuscation details and enhance clarity
|
package/bin/odac.js
CHANGED
|
@@ -532,6 +532,7 @@ async function runMigration(cmd, cliArgs) {
|
|
|
532
532
|
}
|
|
533
533
|
} catch (err) {
|
|
534
534
|
console.error('❌ Migration error:', err.message)
|
|
535
|
+
if (err.cause) console.error(' Cause:', err.cause.message || err.cause)
|
|
535
536
|
process.exit(1)
|
|
536
537
|
} finally {
|
|
537
538
|
for (const conn of Object.values(connections)) {
|
package/client/odac.js
CHANGED
|
@@ -119,6 +119,7 @@ if (typeof window !== 'undefined') {
|
|
|
119
119
|
#page = null
|
|
120
120
|
#token = {hash: [], data: false}
|
|
121
121
|
#formSubmitHandlers = new Map()
|
|
122
|
+
#formElementSelectors = new WeakMap()
|
|
122
123
|
#loader = {elements: {}, callback: null, parts: {}}
|
|
123
124
|
#isNavigating = false
|
|
124
125
|
|
|
@@ -412,6 +413,20 @@ if (typeof window !== 'undefined') {
|
|
|
412
413
|
const formElement = e.target.closest(formSelector)
|
|
413
414
|
if (!formElement) return
|
|
414
415
|
|
|
416
|
+
// Ownership guard: a single form can be matched by more than one
|
|
417
|
+
// registered selector — e.g. an <odac:form> auto-bound by #initForms
|
|
418
|
+
// under its data-odac-form token, then re-bound by the app via
|
|
419
|
+
// Odac.form('#id', cb) to attach a callback. The form is tagged with the
|
|
420
|
+
// selector it was last registered under; only that handler may process
|
|
421
|
+
// the submit, so the form fires exactly once instead of 2x. We don't
|
|
422
|
+
// tear down the other selector's listener (it may still serve *other*
|
|
423
|
+
// forms — e.g. a shared '.ajax-form' selector), we just let those
|
|
424
|
+
// handlers ignore this form. Forms never explicitly (re-)bound aren't
|
|
425
|
+
// tracked and fall through normally, preserving delegation for forms
|
|
426
|
+
// added to the DOM after registration.
|
|
427
|
+
const owner = this.#formElementSelectors.get(formElement)
|
|
428
|
+
if (owner && owner !== formSelector) return
|
|
429
|
+
|
|
415
430
|
e.preventDefault()
|
|
416
431
|
|
|
417
432
|
if (obj.messages !== false) {
|
|
@@ -464,6 +479,43 @@ if (typeof window !== 'undefined') {
|
|
|
464
479
|
errorSpan.textContent = ''
|
|
465
480
|
}
|
|
466
481
|
|
|
482
|
+
if (input.type === 'file') {
|
|
483
|
+
const files = input.files
|
|
484
|
+
if (input.hasAttribute('required') && files.length === 0) {
|
|
485
|
+
showError(input, 'required')
|
|
486
|
+
break
|
|
487
|
+
}
|
|
488
|
+
const maxFiles = parseInt(input.getAttribute('data-maxfiles'))
|
|
489
|
+
if (maxFiles && files.length > maxFiles) {
|
|
490
|
+
showError(input, 'maxfiles')
|
|
491
|
+
break
|
|
492
|
+
}
|
|
493
|
+
const maxSize = parseInt(input.getAttribute('data-maxsize'))
|
|
494
|
+
if (maxSize && [...files].some(f => f.size > maxSize)) {
|
|
495
|
+
showError(input, 'maxsize')
|
|
496
|
+
break
|
|
497
|
+
}
|
|
498
|
+
const minSize = parseInt(input.getAttribute('data-minsize'))
|
|
499
|
+
if (minSize && [...files].some(f => f.size < minSize)) {
|
|
500
|
+
showError(input, 'minsize')
|
|
501
|
+
break
|
|
502
|
+
}
|
|
503
|
+
const accept = input.getAttribute('accept')
|
|
504
|
+
if (accept && files.length) {
|
|
505
|
+
const tokens = accept.split(',').map(t => t.trim().toLowerCase())
|
|
506
|
+
const ok = [...files].every(f =>
|
|
507
|
+
tokens.some(t =>
|
|
508
|
+
t.startsWith('.') ? f.name.toLowerCase().endsWith(t) : t.endsWith('/*') ? f.type.startsWith(t.slice(0, -1)) : f.type === t
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
if (!ok) {
|
|
512
|
+
showError(input, 'accept')
|
|
513
|
+
break
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
continue
|
|
517
|
+
}
|
|
518
|
+
|
|
467
519
|
if (input.hasAttribute('required')) {
|
|
468
520
|
const isEmpty = input.type === 'checkbox' || input.type === 'radio' ? !input.checked : !input.value.trim()
|
|
469
521
|
if (isEmpty) {
|
|
@@ -508,7 +560,7 @@ if (typeof window !== 'undefined') {
|
|
|
508
560
|
let datastring, cache, contentType, processData
|
|
509
561
|
if (formElement.querySelector('input[type=file]')) {
|
|
510
562
|
datastring = new FormData(formElement)
|
|
511
|
-
datastring.append('
|
|
563
|
+
datastring.append('_token', this.token())
|
|
512
564
|
cache = false
|
|
513
565
|
contentType = false
|
|
514
566
|
processData = false
|
|
@@ -680,6 +732,7 @@ if (typeof window !== 'undefined') {
|
|
|
680
732
|
|
|
681
733
|
document.addEventListener('submit', handler)
|
|
682
734
|
this.#formSubmitHandlers.set(formSelector, handler)
|
|
735
|
+
document.querySelectorAll(formSelector).forEach(formEl => this.#formElementSelectors.set(formEl, formSelector))
|
|
683
736
|
}
|
|
684
737
|
|
|
685
738
|
get(url, callback) {
|
|
@@ -997,6 +1050,9 @@ if (typeof window !== 'undefined') {
|
|
|
997
1050
|
|
|
998
1051
|
for (const {cls, attr} of formTypes) {
|
|
999
1052
|
document.querySelectorAll(`form.${cls}[${attr}]`).forEach(form => {
|
|
1053
|
+
// Skip forms the app already bound via Odac.form(...) — re-registering
|
|
1054
|
+
// here would attach a second handler and cause 2x submit.
|
|
1055
|
+
if (this.#formElementSelectors.has(form)) return
|
|
1000
1056
|
const token = form.getAttribute(attr)
|
|
1001
1057
|
const selector = `form[${attr}="${token}"]`
|
|
1002
1058
|
if (!this.#formSubmitHandlers.has(selector)) {
|
|
@@ -101,12 +101,63 @@ module.exports = class Contact {
|
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
## Field-Level Variants
|
|
104
|
-
- **Input types**: `text`, `email`, `password`, `number`, `url`, `textarea`, `checkbox`.
|
|
105
|
-
- **Validation mapping**: `required|minlen|maxlen|min|max|alpha|alphanumeric|numeric|email|url|accepted`.
|
|
106
|
-
- **Pass-through attrs**: Unrecognized `<odac:input ...>` attributes are preserved into generated HTML input/textarea.
|
|
104
|
+
- **Input types**: `text`, `email`, `password`, `number`, `url`, `textarea`, `checkbox`, `file`.
|
|
105
|
+
- **Validation mapping**: `required|minlen|maxlen|min|max|alpha|alphanumeric|numeric|email|url|accepted|maxsize|minsize|mimetype|ext|maxfiles`.
|
|
106
|
+
- **Pass-through attrs**: Unrecognized `<odac:input ...>` attributes are preserved into generated HTML input/textarea/input[type=file].
|
|
107
107
|
- **Skip persistence**: Use `skip` on `<odac:input>` to validate a field but exclude it from final payload.
|
|
108
108
|
- **Unique shorthand**: `unique` attribute on `<odac:input>` enables auth-register uniqueness list.
|
|
109
109
|
|
|
110
|
+
## File Upload Fields
|
|
111
|
+
|
|
112
|
+
File inputs work end-to-end with zero-config validation, like all other input types:
|
|
113
|
+
|
|
114
|
+
```html
|
|
115
|
+
<odac:form action="Profile.saveAvatar" enctype="multipart/form-data">
|
|
116
|
+
<odac:input type="file" name="avatar" label="Avatar">
|
|
117
|
+
<odac:validate rule="required|maxsize:2MB|mimetype:image/png,image/jpeg" message="PNG/JPEG, max 2MB"/>
|
|
118
|
+
</odac:input>
|
|
119
|
+
<odac:submit text="Save"/>
|
|
120
|
+
</odac:form>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### File Rules
|
|
124
|
+
- **`required`**: file must be present (at least one file for `multiple`).
|
|
125
|
+
- **`maxsize:2MB`** / **`minsize:10KB`**: size constraints (supports B, KB, MB, GB suffixes); enforced server-side via busboy limits + client-side byte checks.
|
|
126
|
+
- **`mimetype:image/png,image/jpeg`** (alias **`accept:`**): comma-separated MIME list; wildcards like `image/*` supported. Validated via claimed MIME, extension map, and (for images) magic-byte sniffing.
|
|
127
|
+
- **`ext:jpg,png`**: file extension whitelist (case-insensitive).
|
|
128
|
+
- **`maxfiles:3`**: max file count for `multiple` inputs.
|
|
129
|
+
|
|
130
|
+
### Controller Access
|
|
131
|
+
In action forms, access uploaded files via `form.file(name)`:
|
|
132
|
+
```javascript
|
|
133
|
+
async saveAvatar(form) {
|
|
134
|
+
const avatar = await form.file('avatar')
|
|
135
|
+
if (!avatar) return form.error('avatar', 'No file uploaded')
|
|
136
|
+
|
|
137
|
+
const dest = await avatar.move(`${__dir}/storage/avatars/${user.id}.${avatar.ext}`)
|
|
138
|
+
user.avatar = dest
|
|
139
|
+
return form.success('Avatar saved')
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Table Forms
|
|
144
|
+
For `table="..."` forms, file fields are auto-stored relative to `uploadDir` (configured in `odac.json` or `$ODAC_UPLOAD_DIR`) and the path string is persisted in the column.
|
|
145
|
+
|
|
146
|
+
### File Object Shape
|
|
147
|
+
```javascript
|
|
148
|
+
{
|
|
149
|
+
field: 'avatar',
|
|
150
|
+
name: 'photo.jpg', // original filename
|
|
151
|
+
ext: 'jpg', // extension (lowercase)
|
|
152
|
+
mimetype: 'image/jpeg', // client-claimed
|
|
153
|
+
size: 123456, // bytes
|
|
154
|
+
path: '/tmp/odac-...', // temp path (null if truncated)
|
|
155
|
+
truncated: false, // true if size > maxFileSize
|
|
156
|
+
stored: false, // true after move()
|
|
157
|
+
async move(dest) {...} // move to permanent location
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
110
161
|
## Patterns
|
|
111
162
|
```javascript
|
|
112
163
|
// Custom form action in class/Contact.js
|
|
@@ -37,7 +37,7 @@ module.exports = async Odac => {
|
|
|
37
37
|
- `post(key)`: Validate POST payload field.
|
|
38
38
|
- `get(key)`: Validate querystring field.
|
|
39
39
|
- `var(name, value)`: Validate computed/custom value.
|
|
40
|
-
- `file(name)`: Validate uploaded file object.
|
|
40
|
+
- `file(name)`: Validate uploaded file(s) object.
|
|
41
41
|
- `check(rules | boolean)`: Apply pipe rules or direct boolean validation.
|
|
42
42
|
- `message(text)`: Set message for the latest check on current field.
|
|
43
43
|
- `error()`: Runs validation and returns `true` if any error exists.
|
|
@@ -65,6 +65,14 @@ module.exports = async Odac => {
|
|
|
65
65
|
- `regex:pattern`
|
|
66
66
|
- `mindate:YYYY-MM-DD`, `maxdate:YYYY-MM-DD`
|
|
67
67
|
|
|
68
|
+
### File upload rules (for `file()` fields)
|
|
69
|
+
- `required`: file must be present.
|
|
70
|
+
- `maxsize:2MB` / `minsize:10KB`: size limits (B, KB, MB, GB suffixes).
|
|
71
|
+
- `mimetype:image/png,image/jpeg`: MIME type whitelist; wildcards like `image/*` supported. Validated via claimed MIME, extension map, and (for images) magic-byte sniffing to detect spoofing.
|
|
72
|
+
- `accept:...`: alias for `mimetype:`.
|
|
73
|
+
- `ext:jpg,png`: file extension whitelist (case-insensitive, comma-separated).
|
|
74
|
+
- `maxfiles:N`: max file count for `multiple` file inputs.
|
|
75
|
+
|
|
68
76
|
### Auth/security rules
|
|
69
77
|
- `usercheck`: Must be authenticated.
|
|
70
78
|
- `user:field`: Input must match authenticated user field.
|
|
@@ -125,7 +133,27 @@ module.exports = async Odac => {
|
|
|
125
133
|
}
|
|
126
134
|
```
|
|
127
135
|
|
|
128
|
-
### 4)
|
|
136
|
+
### 4) File upload validation
|
|
137
|
+
```javascript
|
|
138
|
+
module.exports = async Odac => {
|
|
139
|
+
const validator = Odac.validator()
|
|
140
|
+
|
|
141
|
+
validator.file('avatar').check('required|maxsize:2MB|mimetype:image/png,image/jpeg')
|
|
142
|
+
.message('Avatar required (PNG/JPEG, max 2MB)')
|
|
143
|
+
validator.file('documents').check('maxfiles:5|ext:pdf,docx')
|
|
144
|
+
.message('Max 5 PDFs/Word docs allowed')
|
|
145
|
+
|
|
146
|
+
if (await validator.error()) return await validator.result('Upload validation failed')
|
|
147
|
+
|
|
148
|
+
// Access files in controller
|
|
149
|
+
const avatar = await Odac.file('avatar')
|
|
150
|
+
await avatar.move(`${__dir}/storage/avatars/${user.id}.${avatar.ext}`)
|
|
151
|
+
|
|
152
|
+
return await validator.success('Files uploaded')
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 5) Brute-force on auth endpoint
|
|
129
157
|
```javascript
|
|
130
158
|
module.exports = async Odac => {
|
|
131
159
|
const validator = Odac.validator()
|
|
@@ -18,7 +18,7 @@ Frontend logic is organized into **Actions** using `Odac.action()`. This method
|
|
|
18
18
|
- `start`: Fires once when the script initializes.
|
|
19
19
|
- `load`: Fires after every page load (including AJAX navigations).
|
|
20
20
|
3. **Page Scoping**: Use the `page: { name: fn }` object to isolate code to specific routes.
|
|
21
|
-
4. **Persistent Storage**: Use `
|
|
21
|
+
4. **Persistent Storage**: Use `Odac.storage(key, value)` for a secure LocalStorage wrapper.
|
|
22
22
|
|
|
23
23
|
## Reference Patterns
|
|
24
24
|
|
|
@@ -60,11 +60,11 @@ Odac.action({
|
|
|
60
60
|
### 3. Data Utilities
|
|
61
61
|
```javascript
|
|
62
62
|
// Accessing data shared from backend (Odac.share)
|
|
63
|
-
const user =
|
|
63
|
+
const user = Odac.data('user')
|
|
64
64
|
|
|
65
65
|
// Using Storage wrapper
|
|
66
|
-
|
|
67
|
-
const theme =
|
|
66
|
+
Odac.storage('theme', 'dark')
|
|
67
|
+
const theme = Odac.storage('theme')
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
### 4. Programmatic Navigation
|
|
@@ -84,4 +84,4 @@ Odac.load('/dashboard', function(page, vars) {
|
|
|
84
84
|
## Best Practices
|
|
85
85
|
- **Clean Selectors**: Use ID or specific data-attributes for event listeners to avoid conflicts.
|
|
86
86
|
- **No Inline JS**: Move all logic from HTML attributes (onclick, etc.) into `Odac.action()`.
|
|
87
|
-
- **Shared State**: Use `
|
|
87
|
+
- **Shared State**: Use `Odac.data()` to pass complex objects from the backend once per request.
|
|
@@ -15,7 +15,7 @@ Remember the `Odac` object? It's your best friend inside a controller. It's pass
|
|
|
15
15
|
* `Odac.return(data)`: Send back a response.
|
|
16
16
|
* `Odac.direct(url)`: Redirect the user to a new page.
|
|
17
17
|
* `Odac.set(key, value)`: Pass variables to your View template.
|
|
18
|
-
* `Odac.share(key, value)`: Share data directly with frontend JavaScript (`
|
|
18
|
+
* `Odac.share(key, value)`: Share data directly with frontend JavaScript (`Odac.data()`).
|
|
19
19
|
* `Odac.cookie(key, value)`: Set a browser cookie.
|
|
20
20
|
* `Odac.validator()`: Check user input easily.
|
|
21
21
|
* `Odac.setInterval(callback, delay)`: Schedule repeating tasks (auto-cleanup).
|
|
@@ -105,6 +105,16 @@ Supports all standard HTML input types:
|
|
|
105
105
|
<odac:validate rule="minlen:2" message="Name must be at least 2 characters"/>
|
|
106
106
|
<odac:validate rule="maxlen:50" message="Name is too long"/>
|
|
107
107
|
</odac:input>
|
|
108
|
+
|
|
109
|
+
<!-- File upload -->
|
|
110
|
+
<odac:input name="avatar" type="file" label="Profile Picture">
|
|
111
|
+
<odac:validate rule="required|maxsize:2MB|mimetype:image/png,image/jpeg" message="PNG or JPEG, max 2MB"/>
|
|
112
|
+
</odac:input>
|
|
113
|
+
|
|
114
|
+
<!-- Multiple file upload -->
|
|
115
|
+
<odac:input name="documents" type="file" label="Documents" multiple>
|
|
116
|
+
<odac:validate rule="maxfiles:5|ext:pdf,docx" message="Max 5 PDFs or Word docs"/>
|
|
117
|
+
</odac:input>
|
|
108
118
|
```
|
|
109
119
|
|
|
110
120
|
### Field Attributes
|
|
@@ -130,6 +140,7 @@ Add validation rules to fields:
|
|
|
130
140
|
|
|
131
141
|
### Available Rules
|
|
132
142
|
|
|
143
|
+
#### Text/Input Rules
|
|
133
144
|
- `required` - Field is required
|
|
134
145
|
- `email` - Must be valid email
|
|
135
146
|
- `url` - Must be valid URL
|
|
@@ -142,6 +153,15 @@ Add validation rules to fields:
|
|
|
142
153
|
- `alphanumeric` - Letters and numbers only
|
|
143
154
|
- `accepted` - Checkbox must be checked
|
|
144
155
|
|
|
156
|
+
#### File Upload Rules
|
|
157
|
+
- `required` - File must be provided
|
|
158
|
+
- `maxsize:2MB` - Maximum file size (supports B, KB, MB, GB)
|
|
159
|
+
- `minsize:10KB` - Minimum file size
|
|
160
|
+
- `mimetype:image/png,image/jpeg` - Allowed MIME types (comma-separated; wildcards like `image/*` supported)
|
|
161
|
+
- `accept:...` - Alias for `mimetype:`
|
|
162
|
+
- `ext:jpg,png` - Allowed file extensions (case-insensitive)
|
|
163
|
+
- `maxfiles:5` - Maximum number of files for `multiple` uploads
|
|
164
|
+
|
|
145
165
|
### Multiple Rules
|
|
146
166
|
|
|
147
167
|
Combine rules with `|`:
|
|
@@ -204,6 +224,50 @@ Automatically set field values without user input:
|
|
|
204
224
|
<odac:submit text="Save" loading="Saving..." class="btn btn-primary" id="save-btn"/>
|
|
205
225
|
```
|
|
206
226
|
|
|
227
|
+
## Client-Side Callbacks (Optional)
|
|
228
|
+
|
|
229
|
+
`<odac:form>` works fully on its own — submission, CSRF, validation, loading
|
|
230
|
+
states, success/error messages and redirects are all wired up automatically,
|
|
231
|
+
**no JavaScript required.**
|
|
232
|
+
|
|
233
|
+
If you also need to run your own code after a submit (analytics, closing a
|
|
234
|
+
modal, updating the UI, etc.), give the form an `id` and attach a callback with
|
|
235
|
+
`Odac.form()`:
|
|
236
|
+
|
|
237
|
+
```html
|
|
238
|
+
<odac:form action="Account.unlink" id="server-unlink">
|
|
239
|
+
<!-- fields -->
|
|
240
|
+
<odac:submit text="Unlink"/>
|
|
241
|
+
</odac:form>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
```javascript
|
|
245
|
+
Odac.form('#server-unlink', function(data) {
|
|
246
|
+
if (data.result.success) {
|
|
247
|
+
closeModal()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
This is **safe to combine** with the automatic registration — calling
|
|
253
|
+
`Odac.form()` on an `<odac:form>` re-binds the same form rather than adding a
|
|
254
|
+
second handler, so the form still submits only once. (Internally the dedup is
|
|
255
|
+
keyed by the form element, not the selector string.)
|
|
256
|
+
|
|
257
|
+
> **Note:** Call `Odac.form()` after the form exists in the DOM (e.g. on
|
|
258
|
+
> `DOMContentLoaded` or after AJAX navigation). Binding before the element is
|
|
259
|
+
> rendered can leave the automatic registration in place as a second handler.
|
|
260
|
+
|
|
261
|
+
You can trigger submission programmatically with the native API:
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
document.getElementById('server-unlink').requestSubmit()
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
For the full callback/options API (`messages`, `loading`, redirect strings,
|
|
268
|
+
file-upload progress) see
|
|
269
|
+
[Form Handling with odac.js](../../frontend/03-forms/01-form-handling.md).
|
|
270
|
+
|
|
207
271
|
## Controller Handler (Server Actions)
|
|
208
272
|
|
|
209
273
|
Handle form submission directly in your controller. The action is defined as `ControllerName.methodName`.
|
|
@@ -262,6 +326,38 @@ module.exports = class Contact {
|
|
|
262
326
|
|
|
263
327
|
**Note:** No route definition is needed for the action! The form system handles the routing securely.
|
|
264
328
|
|
|
329
|
+
## File Upload Handling
|
|
330
|
+
|
|
331
|
+
Access uploaded files in your controller via `form.file(name)`:
|
|
332
|
+
|
|
333
|
+
```javascript
|
|
334
|
+
module.exports = class Profile {
|
|
335
|
+
constructor(Odac) {
|
|
336
|
+
this.Odac = Odac
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async updateAvatar(form) {
|
|
340
|
+
const avatar = await form.file('avatar')
|
|
341
|
+
if (!avatar) {
|
|
342
|
+
return form.error('avatar', 'No file uploaded')
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Store the file permanently
|
|
346
|
+
const userId = this.Odac.Auth.user('id')
|
|
347
|
+
const dest = await avatar.move(`${__dir}/../storage/avatars/${userId}.${avatar.ext}`)
|
|
348
|
+
|
|
349
|
+
// Update user record with file path
|
|
350
|
+
// await this.Odac.DB.users.where('id', userId).update({avatar: dest})
|
|
351
|
+
|
|
352
|
+
return form.success('Avatar updated!', '/profile')
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
For `table="..."` forms with file fields, files are automatically stored in the configured `uploadDir` and the file path is saved to the database column.
|
|
358
|
+
|
|
359
|
+
> **Security:** `move(dest)` writes to whatever path you pass, just like `fs` — the file object's own `.name` is already stripped to a bare filename, but if you build `dest` from raw user input, sanitize it yourself to avoid path traversal. Server-generated names (as in the example above) are always safe. Uploads are validated for size/type before your handler runs, but content sniffing only covers raster images (JPEG/PNG/GIF/WebP); other types are trusted by extension + declared MIME, and `image/*` also permits SVG (an XSS vector) — list explicit types like `mimetype:image/png,image/jpeg` when you need to exclude it.
|
|
360
|
+
|
|
265
361
|
## Automatic Database Insert
|
|
266
362
|
|
|
267
363
|
Forms can automatically insert data into database without writing a controller:
|
|
@@ -72,6 +72,15 @@ if (await validator.error()) {
|
|
|
72
72
|
- `regex:pattern` - Must match regex pattern
|
|
73
73
|
- `!disposable` - Block disposable/temporary email providers (List is automatically updated daily)
|
|
74
74
|
|
|
75
|
+
**File Upload Validation:**
|
|
76
|
+
- `required` - File must be uploaded
|
|
77
|
+
- `maxsize:2MB` - Maximum file size (B, KB, MB, GB)
|
|
78
|
+
- `minsize:10KB` - Minimum file size
|
|
79
|
+
- `mimetype:image/png,image/jpeg` - Allowed MIME types (comma-separated; wildcards like `image/*` supported)
|
|
80
|
+
- `accept:...` - Alias for `mimetype:`
|
|
81
|
+
- `ext:jpg,png` - Allowed file extensions (case-insensitive)
|
|
82
|
+
- `maxfiles:5` - Max files for multi-upload
|
|
83
|
+
|
|
75
84
|
**Security:**
|
|
76
85
|
- `xss` - Check for HTML tags (XSS protection)
|
|
77
86
|
- `usercheck` - User must be authenticated
|
|
@@ -358,6 +367,37 @@ module.exports = async function (Odac) {
|
|
|
358
367
|
}
|
|
359
368
|
```
|
|
360
369
|
|
|
370
|
+
#### Example: File Upload Validation
|
|
371
|
+
|
|
372
|
+
```javascript
|
|
373
|
+
module.exports = async function (Odac) {
|
|
374
|
+
const validator = Odac.Validator
|
|
375
|
+
|
|
376
|
+
validator
|
|
377
|
+
.file('avatar')
|
|
378
|
+
.check('required').message('Avatar is required')
|
|
379
|
+
.check('maxsize:2MB').message('Avatar must not exceed 2MB')
|
|
380
|
+
.check('mimetype:image/png,image/jpeg').message('Avatar must be PNG or JPEG')
|
|
381
|
+
|
|
382
|
+
validator
|
|
383
|
+
.file('documents')
|
|
384
|
+
.check('maxfiles:5').message('Maximum 5 documents allowed')
|
|
385
|
+
.check('ext:pdf,docx').message('Only PDF and Word documents allowed')
|
|
386
|
+
|
|
387
|
+
if (await validator.error()) {
|
|
388
|
+
return validator.result('Upload validation failed')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Access and store the files
|
|
392
|
+
const avatar = await Odac.file('avatar')
|
|
393
|
+
const userId = Odac.Auth.user('id')
|
|
394
|
+
|
|
395
|
+
await avatar.move(`${__dir}/../storage/avatars/${userId}.${avatar.ext}`)
|
|
396
|
+
|
|
397
|
+
return validator.success('Files uploaded successfully')
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
361
401
|
#### Frontend Integration
|
|
362
402
|
|
|
363
403
|
When using `Odac.form()` on the frontend, validation errors are automatically displayed:
|
|
@@ -88,13 +88,13 @@ Odac.action({
|
|
|
88
88
|
|
|
89
89
|
```javascript
|
|
90
90
|
// Basic usage
|
|
91
|
-
|
|
91
|
+
Odac.form('#my-form', function(data) {
|
|
92
92
|
// This callback is executed on success
|
|
93
93
|
console.log('Form submitted successfully!', data);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
96
|
// With options
|
|
97
|
-
|
|
97
|
+
Odac.form({
|
|
98
98
|
form: '#my-form',
|
|
99
99
|
messages: ['success', 'error'], // Show both success and error messages
|
|
100
100
|
loading: function(percent) {
|
|
@@ -119,7 +119,7 @@ To display validation errors, you can add elements with the `odac-form-error` at
|
|
|
119
119
|
For simple GET requests, you can use the `Odac.get()` method.
|
|
120
120
|
|
|
121
121
|
```javascript
|
|
122
|
-
|
|
122
|
+
Odac.get('/api/users', function(data) {
|
|
123
123
|
console.log('Users:', data);
|
|
124
124
|
});
|
|
125
125
|
```
|
|
@@ -133,18 +133,18 @@ odac.get('/api/users', function(data) {
|
|
|
133
133
|
- **`Odac.client()`**: Returns a unique client identifier from a cookie.
|
|
134
134
|
- **`Odac.data(key)`**: Returns shared data passed from the backend via `Odac.share`. You can get the full data object or a specific key:
|
|
135
135
|
```javascript
|
|
136
|
-
let allData =
|
|
137
|
-
let user =
|
|
136
|
+
let allData = Odac.data();
|
|
137
|
+
let user = Odac.data('user'); // Returns null if not exists
|
|
138
138
|
```
|
|
139
139
|
- **`Odac.page()`**: Returns the identifier of the current page. This is the controller name (e.g., `'user'`) or view name (e.g., `'dashboard'`) set by the backend. Use this to conditionally run code for specific pages.
|
|
140
140
|
- **`Odac.storage()`**: A wrapper for `localStorage`.
|
|
141
141
|
```javascript
|
|
142
142
|
// Set a value
|
|
143
|
-
|
|
143
|
+
Odac.storage('my-key', 'my-value');
|
|
144
144
|
|
|
145
145
|
// Get a value
|
|
146
|
-
let value =
|
|
146
|
+
let value = Odac.storage('my-key');
|
|
147
147
|
|
|
148
148
|
// Remove a value
|
|
149
|
-
|
|
149
|
+
Odac.storage('my-key', null);
|
|
150
150
|
```
|
|
@@ -126,17 +126,17 @@ Controllers automatically support AJAX loading. Use `Odac.View.skeleton()` to sp
|
|
|
126
126
|
```javascript
|
|
127
127
|
module.exports = function (Odac) {
|
|
128
128
|
// Define the skeleton template
|
|
129
|
-
|
|
129
|
+
Odac.View.skeleton('main')
|
|
130
130
|
|
|
131
131
|
// Set view parts - lowercase keys map to UPPERCASE placeholders
|
|
132
|
-
|
|
132
|
+
Odac.View.set({
|
|
133
133
|
header: 'main', // Loads view/header/main.html into {{ HEADER }}
|
|
134
134
|
content: 'about', // Loads view/content/about.html into {{ CONTENT }}
|
|
135
135
|
footer: 'main' // Loads view/footer/main.html into {{ FOOTER }}
|
|
136
136
|
})
|
|
137
137
|
|
|
138
138
|
// Optional: Send variables to frontend (AJAX only)
|
|
139
|
-
|
|
139
|
+
Odac.set({
|
|
140
140
|
pageTitle: 'About',
|
|
141
141
|
data: {foo: 'bar'}
|
|
142
142
|
}, true) // true = include in AJAX responses
|
|
@@ -318,13 +318,13 @@ Both methods work automatically - no additional configuration needed!
|
|
|
318
318
|
}
|
|
319
319
|
```
|
|
320
320
|
|
|
321
|
-
###
|
|
321
|
+
### Odac.loader(selector, elements, callback)
|
|
322
322
|
|
|
323
323
|
Low-level method for direct initialization (not recommended for new code).
|
|
324
324
|
|
|
325
325
|
**Parameters:** Same as navigate configuration, but as separate arguments.
|
|
326
326
|
|
|
327
|
-
###
|
|
327
|
+
### Odac.load(url, callback, push)
|
|
328
328
|
|
|
329
329
|
Programmatically load a page via AJAX.
|
|
330
330
|
|
|
@@ -335,7 +335,7 @@ Programmatically load a page via AJAX.
|
|
|
335
335
|
|
|
336
336
|
**Example:**
|
|
337
337
|
```javascript
|
|
338
|
-
|
|
338
|
+
Odac.load('/about', function(page, variables) {
|
|
339
339
|
console.log('Loaded:', page)
|
|
340
340
|
})
|
|
341
341
|
```
|
|
@@ -377,7 +377,7 @@ Send data from server to client in AJAX responses:
|
|
|
377
377
|
|
|
378
378
|
```javascript
|
|
379
379
|
// In controller
|
|
380
|
-
|
|
380
|
+
Odac.set({
|
|
381
381
|
user: {name: 'John', role: 'admin'},
|
|
382
382
|
stats: {views: 1234}
|
|
383
383
|
}, true) // true = include in AJAX
|
|
@@ -423,12 +423,12 @@ Odac.action({
|
|
|
423
423
|
navigate: {
|
|
424
424
|
update: 'main',
|
|
425
425
|
on: function(page, variables) {
|
|
426
|
-
|
|
426
|
+
Odac.fn.updateActiveNav(window.location.pathname)
|
|
427
427
|
console.log('Navigated to:', page)
|
|
428
428
|
}
|
|
429
429
|
},
|
|
430
430
|
|
|
431
|
-
// Custom functions (accessible as
|
|
431
|
+
// Custom functions (accessible as Odac.fn.functionName)
|
|
432
432
|
function: {
|
|
433
433
|
updateActiveNav: function(url) {
|
|
434
434
|
document.querySelectorAll('nav a').forEach(link => {
|
|
@@ -440,14 +440,14 @@ Odac.action({
|
|
|
440
440
|
// App initialization
|
|
441
441
|
load: function() {
|
|
442
442
|
console.log('App initialized')
|
|
443
|
-
|
|
443
|
+
Odac.fn.updateActiveNav(window.location.pathname)
|
|
444
444
|
},
|
|
445
445
|
|
|
446
446
|
// Page-specific code
|
|
447
447
|
page: {
|
|
448
448
|
index: function(variables) {
|
|
449
449
|
// Home page specific code
|
|
450
|
-
|
|
450
|
+
Odac.form('#contact-form', function(data) {
|
|
451
451
|
if (data.result.success) {
|
|
452
452
|
alert('Message sent!')
|
|
453
453
|
}
|
|
@@ -463,7 +463,7 @@ Odac.action({
|
|
|
463
463
|
// Event handlers
|
|
464
464
|
click: {
|
|
465
465
|
'#refresh-btn': function() {
|
|
466
|
-
|
|
466
|
+
Odac.load(window.location.pathname)
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
469
|
})
|
|
@@ -595,8 +595,8 @@ This is usually caused by mismatched keys between your skeleton, controller, and
|
|
|
595
595
|
|
|
596
596
|
2. **Controller** (`controller/page/about.js`):
|
|
597
597
|
```javascript
|
|
598
|
-
|
|
599
|
-
|
|
598
|
+
Odac.View.skeleton('main')
|
|
599
|
+
Odac.View.set({
|
|
600
600
|
header: 'main', // Lowercase → {{ HEADER }}
|
|
601
601
|
content: 'about' // Lowercase → {{ CONTENT }}
|
|
602
602
|
})
|
|
@@ -129,7 +129,7 @@ The keys (content, header, etc.) must match the view parts defined in your contr
|
|
|
129
129
|
|
|
130
130
|
```javascript
|
|
131
131
|
// In controller
|
|
132
|
-
|
|
132
|
+
Odac.View.set({
|
|
133
133
|
header: 'main',
|
|
134
134
|
content: 'about',
|
|
135
135
|
sidebar: 'main'
|
|
@@ -219,14 +219,14 @@ In your controller, send data to the client:
|
|
|
219
219
|
```javascript
|
|
220
220
|
module.exports = function(Odac) {
|
|
221
221
|
// Set variables for AJAX responses
|
|
222
|
-
|
|
222
|
+
Odac.set({
|
|
223
223
|
title: 'About Page',
|
|
224
224
|
user: {name: 'John', role: 'admin'},
|
|
225
225
|
stats: {views: 1234}
|
|
226
226
|
}, true) // true = include in AJAX
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
228
|
+
Odac.View.skeleton('main')
|
|
229
|
+
Odac.View.set({
|
|
230
230
|
header: 'main',
|
|
231
231
|
content: 'about',
|
|
232
232
|
footer: 'main'
|