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/src/Request.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
const nodeCrypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const fsPromises = fs.promises
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const os = require('os')
|
|
2
6
|
|
|
3
7
|
class OdacRequest {
|
|
4
8
|
#odac
|
|
5
9
|
#complete = false
|
|
6
10
|
#cookies = {data: {}, sent: []}
|
|
7
11
|
data = {post: {}, get: {}, url: {}}
|
|
12
|
+
#files = {}
|
|
13
|
+
#activeWrites = new Set()
|
|
14
|
+
#cleanedUp = false
|
|
8
15
|
#event = {data: [], end: []}
|
|
9
16
|
#headers = {Server: 'Odac'}
|
|
10
17
|
#status = 200
|
|
@@ -36,16 +43,25 @@ class OdacRequest {
|
|
|
36
43
|
if (!global.Odac.Route.routes[route]) route = 'www'
|
|
37
44
|
this.route = route
|
|
38
45
|
if (this.res) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
this.#timeout = setTimeout(() => !this.res.finished && this.abort(408), global.Odac.Config.request.timeout)
|
|
43
|
-
}
|
|
46
|
+
this.#armTimeout()
|
|
47
|
+
// Client disconnected before we responded (e.g. aborted upload): drop temp files.
|
|
48
|
+
if (typeof this.res.on === 'function') this.res.on('close', () => this.#cleanupFiles())
|
|
44
49
|
}
|
|
45
50
|
this.#data()
|
|
46
51
|
if (!global.Odac.Request) global.Odac.Request = {}
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
// (Re)arm the idle timeout. Called once at construction and again on every
|
|
55
|
+
// upload data chunk so a slow-but-progressing upload isn't killed at the
|
|
56
|
+
// fixed timeout; the timer now measures idle time, not total request time.
|
|
57
|
+
#armTimeout() {
|
|
58
|
+
if (!this.res || this.res.finished) return
|
|
59
|
+
clearTimeout(this.#timeout)
|
|
60
|
+
const fn = () => !this.res.finished && this.abort(408)
|
|
61
|
+
const ms = global.Odac.Config.request.timeout
|
|
62
|
+
this.#timeout = typeof this.#odac.setTimeout === 'function' ? this.#odac.setTimeout(fn, ms) : setTimeout(fn, ms)
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
// - ABORT REQUEST
|
|
50
66
|
async abort(code) {
|
|
51
67
|
this.status(code)
|
|
@@ -100,50 +116,41 @@ class OdacRequest {
|
|
|
100
116
|
this.data.get[key] = val
|
|
101
117
|
}
|
|
102
118
|
}
|
|
119
|
+
|
|
120
|
+
const contentType = this.req.headers['content-type'] || ''
|
|
121
|
+
if (contentType.startsWith('multipart/form-data')) {
|
|
122
|
+
return this.#multipart()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Non-multipart: urlencoded or JSON (buffered path)
|
|
103
126
|
let body = ''
|
|
104
127
|
this.req.on('data', chunk => {
|
|
105
128
|
body += chunk.toString()
|
|
106
|
-
|
|
129
|
+
const maxBodySize = global.Odac.Config.request.maxBodySize
|
|
130
|
+
if (body.length > maxBodySize) {
|
|
107
131
|
body = ''
|
|
108
132
|
this.status(413)
|
|
109
133
|
this.end()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (boundary.includes('boundary=')) {
|
|
115
|
-
try {
|
|
116
|
-
boundary = boundary.split('boundary=')[1].split(';')[0].trim()
|
|
117
|
-
} catch {
|
|
118
|
-
// ignore
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
let data = body.split(boundary)
|
|
122
|
-
for (let i = 0; i < data.length; i++) {
|
|
123
|
-
if (data[i].indexOf('Content-Disposition') === -1) continue
|
|
124
|
-
let key = data[i].split('name="')[1].split('"')[0]
|
|
125
|
-
let val = data[i].split('\r\n\r\n')[1].split('\r\n')[0]
|
|
126
|
-
this.data.post[key] = val
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
let data = body.split('&')
|
|
130
|
-
for (let i = 0; i < data.length; i++) {
|
|
131
|
-
if (data[i].indexOf('=') === -1) continue
|
|
132
|
-
let key = decodeURIComponent(data[i].split('=')[0])
|
|
133
|
-
let val = decodeURIComponent(data[i].split('=')[1] || '')
|
|
134
|
-
this.data.post[key] = val
|
|
135
|
-
}
|
|
136
|
-
}
|
|
134
|
+
this.req.removeAllListeners('data')
|
|
135
|
+
this.req.resume()
|
|
136
|
+
this.#complete = true
|
|
137
|
+
return
|
|
137
138
|
}
|
|
139
|
+
|
|
138
140
|
for (const event of this.#event.data) {
|
|
139
141
|
event.callback(event.active ? chunk : body)
|
|
140
142
|
event.active = true
|
|
141
143
|
}
|
|
142
144
|
})
|
|
145
|
+
|
|
143
146
|
this.req.on('end', () => {
|
|
144
147
|
if (!body) return (this.#complete = true)
|
|
145
148
|
if (body.startsWith('{') && body.endsWith('}')) {
|
|
146
|
-
|
|
149
|
+
try {
|
|
150
|
+
this.data.post = JSON.parse(body)
|
|
151
|
+
} catch {
|
|
152
|
+
// invalid JSON, ignore
|
|
153
|
+
}
|
|
147
154
|
} else {
|
|
148
155
|
let data = body.split('&')
|
|
149
156
|
for (let i = 0; i < data.length; i++) {
|
|
@@ -158,6 +165,161 @@ class OdacRequest {
|
|
|
158
165
|
})
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
async #multipart() {
|
|
169
|
+
const cfg = global.Odac.Config.request
|
|
170
|
+
const busboy = require('busboy')
|
|
171
|
+
|
|
172
|
+
const uploadDir = cfg.uploadDir || path.join(os.tmpdir(), 'odac-uploads')
|
|
173
|
+
try {
|
|
174
|
+
await fsPromises.mkdir(uploadDir, {recursive: true})
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error('Failed to create upload directory:', err.message)
|
|
177
|
+
this.status(500)
|
|
178
|
+
this.end()
|
|
179
|
+
this.#complete = true
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const bb = busboy({
|
|
184
|
+
headers: this.req.headers,
|
|
185
|
+
defParamCharset: 'utf8',
|
|
186
|
+
limits: {fileSize: cfg.maxFileSize, files: cfg.maxFiles, fieldSize: cfg.maxBodySize, fields: 200}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// busboy 'close' can fire before our writeStreams flush to disk, so a file
|
|
190
|
+
// isn't complete until BOTH the parser closed and every pending write ended.
|
|
191
|
+
// Otherwise req.file() could resolve before #files is populated.
|
|
192
|
+
let bbClosed = false
|
|
193
|
+
const finalize = () => {
|
|
194
|
+
if (this.#complete || !bbClosed || this.#activeWrites.size > 0) return
|
|
195
|
+
this.#complete = true
|
|
196
|
+
for (const event of this.#event.end) event.callback()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
bb.on('field', (name, val) => {
|
|
200
|
+
this.data.post[name] = val
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
bb.on('file', (fieldname, stream, info) => {
|
|
204
|
+
// Empty optional file input: browsers send filename === '' for untouched input
|
|
205
|
+
if (!info.filename) {
|
|
206
|
+
stream.resume()
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const fileExt = path.extname(info.filename).toLowerCase().slice(1) || 'bin'
|
|
211
|
+
const tmpPath = path.join(uploadDir, `odac-${nodeCrypto.randomBytes(16).toString('hex')}`)
|
|
212
|
+
const writeStream = fs.createWriteStream(tmpPath)
|
|
213
|
+
|
|
214
|
+
const active = {writeStream, tmpPath}
|
|
215
|
+
this.#activeWrites.add(active)
|
|
216
|
+
|
|
217
|
+
let fileSize = 0
|
|
218
|
+
let truncated = false
|
|
219
|
+
|
|
220
|
+
stream.on('data', chunk => {
|
|
221
|
+
fileSize += chunk.length
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// busboy stops feeding data once fileSize limit is hit and lets the
|
|
225
|
+
// stream end naturally; just flag it so the writeStream 'finish' handler
|
|
226
|
+
// can drop the partial file and record a truncated metadata object.
|
|
227
|
+
stream.on('limit', () => {
|
|
228
|
+
truncated = true
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
stream.pipe(writeStream)
|
|
232
|
+
|
|
233
|
+
writeStream.on('finish', () => {
|
|
234
|
+
this.#activeWrites.delete(active)
|
|
235
|
+
|
|
236
|
+
// Oversize file: remove the partial write but still surface metadata so
|
|
237
|
+
// the validator reports a per-field "too large" error instead of hanging.
|
|
238
|
+
if (truncated) fs.unlink(tmpPath, () => {})
|
|
239
|
+
|
|
240
|
+
const fileObj = {
|
|
241
|
+
field: fieldname,
|
|
242
|
+
name: path.basename(info.filename),
|
|
243
|
+
ext: fileExt,
|
|
244
|
+
mimetype: info.mimeType,
|
|
245
|
+
size: fileSize,
|
|
246
|
+
path: truncated ? null : tmpPath,
|
|
247
|
+
truncated: truncated,
|
|
248
|
+
stored: false,
|
|
249
|
+
move: async dest => this.#moveFile(fileObj, dest)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!Array.isArray(this.#files[fieldname])) {
|
|
253
|
+
this.#files[fieldname] = []
|
|
254
|
+
}
|
|
255
|
+
this.#files[fieldname].push(fileObj)
|
|
256
|
+
finalize()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
writeStream.on('error', () => {
|
|
260
|
+
this.#activeWrites.delete(active)
|
|
261
|
+
stream.resume()
|
|
262
|
+
fs.unlink(tmpPath, () => {})
|
|
263
|
+
finalize()
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
bb.on('close', () => {
|
|
268
|
+
bbClosed = true
|
|
269
|
+
finalize()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
bb.on('error', err => {
|
|
273
|
+
if (this.#complete) return
|
|
274
|
+
console.error('Busboy error:', err.message)
|
|
275
|
+
this.#complete = true
|
|
276
|
+
for (const event of this.#event.end) event.callback()
|
|
277
|
+
// Malformed multipart body: send 400 instead of letting the handler run on
|
|
278
|
+
// partial data. #complete is already set so any pending req.file()/request()
|
|
279
|
+
// pollers resolve, and res.finished blocks any later controller response.
|
|
280
|
+
this.abort(400)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// Keep the timeout as an *idle* deadline: reset it while upload data keeps
|
|
284
|
+
// arriving so large-but-progressing uploads aren't aborted at 408.
|
|
285
|
+
this.req.on('data', () => this.#armTimeout())
|
|
286
|
+
this.req.pipe(bb)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Move the uploaded temp file to a permanent, caller-chosen location. The
|
|
290
|
+
// destination is developer-controlled (like any fs call): if you build it from
|
|
291
|
+
// raw user input, sanitize it yourself — the file object's own `.name` is
|
|
292
|
+
// already basename-only. Handles cross-device moves via copy+unlink fallback.
|
|
293
|
+
async #moveFile(fileObj, dest) {
|
|
294
|
+
if (!fileObj.path || fileObj.stored) {
|
|
295
|
+
throw new Error('Cannot move a truncated or already-moved file')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const resolved = path.resolve(dest)
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
await fsPromises.mkdir(path.dirname(resolved), {recursive: true})
|
|
302
|
+
} catch {
|
|
303
|
+
// directory may already exist
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await fsPromises.rename(fileObj.path, resolved)
|
|
308
|
+
} catch (err) {
|
|
309
|
+
if (err.code === 'EXDEV') {
|
|
310
|
+
// Source and destination are on different filesystems: copy then remove.
|
|
311
|
+
await fsPromises.copyFile(fileObj.path, resolved)
|
|
312
|
+
await fsPromises.unlink(fileObj.path)
|
|
313
|
+
} else {
|
|
314
|
+
throw err
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fileObj.path = resolved
|
|
319
|
+
fileObj.stored = true
|
|
320
|
+
return resolved
|
|
321
|
+
}
|
|
322
|
+
|
|
161
323
|
// - RETURN REQUEST
|
|
162
324
|
end(data) {
|
|
163
325
|
if (data instanceof Promise) return data.then(result => this.end(result))
|
|
@@ -172,9 +334,32 @@ class OdacRequest {
|
|
|
172
334
|
clearTimeout(this.#timeout)
|
|
173
335
|
this.print()
|
|
174
336
|
this.res.end(data)
|
|
337
|
+
this.#cleanupFiles()
|
|
175
338
|
this.req.connection.destroy()
|
|
176
339
|
}
|
|
177
340
|
|
|
341
|
+
// Remove temp files that were never moved to permanent storage. Runs after a
|
|
342
|
+
// normal response (end) and on early client disconnect (res 'close'), covering
|
|
343
|
+
// both in-progress writes (aborted mid-upload) and finished-but-unstored files.
|
|
344
|
+
#cleanupFiles() {
|
|
345
|
+
if (this.#cleanedUp) return
|
|
346
|
+
this.#cleanedUp = true
|
|
347
|
+
|
|
348
|
+
for (const active of this.#activeWrites) {
|
|
349
|
+
active.writeStream.destroy()
|
|
350
|
+
fs.unlink(active.tmpPath, () => {})
|
|
351
|
+
}
|
|
352
|
+
this.#activeWrites.clear()
|
|
353
|
+
|
|
354
|
+
for (const fieldFiles of Object.values(this.#files)) {
|
|
355
|
+
for (const file of fieldFiles) {
|
|
356
|
+
if (!file.stored && file.path) {
|
|
357
|
+
fs.unlink(file.path, () => {})
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
178
363
|
// - GET
|
|
179
364
|
get(key) {
|
|
180
365
|
return this.variables[key] ? this.variables[key].value : null
|
|
@@ -242,6 +427,22 @@ class OdacRequest {
|
|
|
242
427
|
})
|
|
243
428
|
}
|
|
244
429
|
|
|
430
|
+
// - GET FILE
|
|
431
|
+
async file(name) {
|
|
432
|
+
const resolveFiles = () => {
|
|
433
|
+
if (!name) return this.#files
|
|
434
|
+
const arr = this.#files[name]
|
|
435
|
+
if (!arr || arr.length === 0) return null
|
|
436
|
+
return arr.length === 1 ? arr[0] : arr
|
|
437
|
+
}
|
|
438
|
+
if (this.#complete) return resolveFiles()
|
|
439
|
+
return new Promise(resolve => {
|
|
440
|
+
this.#event.end.push({
|
|
441
|
+
callback: () => resolve(resolveFiles())
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
245
446
|
setSession() {
|
|
246
447
|
if (!this.cookie('odac_client') || !this.session('_client') || this.session('_client') !== this.cookie('odac_client')) {
|
|
247
448
|
let client = nodeCrypto.randomBytes(16).toString('hex')
|
package/src/Route/Internal.js
CHANGED
|
@@ -4,7 +4,7 @@ class Internal {
|
|
|
4
4
|
static #validateField(validator, field, validation, value) {
|
|
5
5
|
const rules = validation.rule.split('|')
|
|
6
6
|
for (const rule of rules) {
|
|
7
|
-
const validatorChain = validator.post(field.name).check(rule)
|
|
7
|
+
const validatorChain = (field.type === 'file' ? validator.file(field.name) : validator.post(field.name)).check(rule)
|
|
8
8
|
if (validation.message) {
|
|
9
9
|
const message = this.replacePlaceholders(validation.message, {
|
|
10
10
|
value: value,
|
|
@@ -71,6 +71,8 @@ class Internal {
|
|
|
71
71
|
const uniqueFields = []
|
|
72
72
|
|
|
73
73
|
for (const field of config.fields) {
|
|
74
|
+
if (field.type === 'file') continue
|
|
75
|
+
|
|
74
76
|
const value = await Odac.request(field.name)
|
|
75
77
|
|
|
76
78
|
for (const validation of field.validations) {
|
|
@@ -408,7 +410,7 @@ class Internal {
|
|
|
408
410
|
const uniqueFields = []
|
|
409
411
|
|
|
410
412
|
for (const field of config.fields) {
|
|
411
|
-
const value = await Odac.request(field.name)
|
|
413
|
+
const value = field.type === 'file' ? await Odac.file(field.name) : await Odac.request(field.name)
|
|
412
414
|
|
|
413
415
|
for (const validation of field.validations) {
|
|
414
416
|
this.#validateField(validator, field, validation, value)
|
|
@@ -420,7 +422,8 @@ class Internal {
|
|
|
420
422
|
}
|
|
421
423
|
}
|
|
422
424
|
|
|
423
|
-
|
|
425
|
+
// File fields excluded from data bag; controllers access via formHelper.file()
|
|
426
|
+
if (!field.skip && field.type !== 'file') {
|
|
424
427
|
data[field.name] = value
|
|
425
428
|
}
|
|
426
429
|
}
|
|
@@ -498,6 +501,8 @@ class Internal {
|
|
|
498
501
|
if (Odac.formConfig.table) {
|
|
499
502
|
try {
|
|
500
503
|
const table = Odac.DB[Odac.formConfig.table]
|
|
504
|
+
const path = require('path')
|
|
505
|
+
const os = require('os')
|
|
501
506
|
|
|
502
507
|
for (const field of Odac.formUniqueFields) {
|
|
503
508
|
if (Odac.formData[field.name] == null) continue
|
|
@@ -513,6 +518,26 @@ class Internal {
|
|
|
513
518
|
}
|
|
514
519
|
}
|
|
515
520
|
|
|
521
|
+
// Auto-store file fields for table forms. Skipped fields are validated
|
|
522
|
+
// but excluded from persistence (mirrors `skip` for text fields); their
|
|
523
|
+
// temp files are cleaned up by Request when the response is sent.
|
|
524
|
+
const uploadDir = global.Odac.Config.request.uploadDir || path.join(os.tmpdir(), 'odac-uploads')
|
|
525
|
+
for (const field of Odac.formConfig.fields) {
|
|
526
|
+
if (field.type !== 'file' || field.skip) continue
|
|
527
|
+
|
|
528
|
+
const file = await Odac.file(field.name)
|
|
529
|
+
if (!file) continue
|
|
530
|
+
|
|
531
|
+
const files = Array.isArray(file) ? file : [file]
|
|
532
|
+
const storedPaths = []
|
|
533
|
+
for (const f of files) {
|
|
534
|
+
const rel = path.join(Odac.formConfig.table, `${nodeCrypto.randomBytes(8).toString('hex')}.${f.ext}`)
|
|
535
|
+
await f.move(path.join(uploadDir, rel))
|
|
536
|
+
storedPaths.push(rel)
|
|
537
|
+
}
|
|
538
|
+
Odac.formData[field.name] = Array.isArray(file) ? JSON.stringify(storedPaths) : storedPaths[0]
|
|
539
|
+
}
|
|
540
|
+
|
|
516
541
|
await table.insert(Odac.formData)
|
|
517
542
|
|
|
518
543
|
Odac.Request.session(`_custom_form_${token}`, null)
|
|
@@ -556,6 +581,10 @@ class Internal {
|
|
|
556
581
|
const formHelper = {
|
|
557
582
|
data: Odac.formData,
|
|
558
583
|
|
|
584
|
+
file: name => {
|
|
585
|
+
return Odac.file(name)
|
|
586
|
+
},
|
|
587
|
+
|
|
559
588
|
error: (field, message) => {
|
|
560
589
|
return Odac.return({
|
|
561
590
|
result: {success: false},
|
package/src/Validator.js
CHANGED
|
@@ -173,11 +173,27 @@ class Validator {
|
|
|
173
173
|
let error = false
|
|
174
174
|
let rules = checkItem.rules
|
|
175
175
|
|
|
176
|
+
// Normalize file value to array
|
|
177
|
+
let files = []
|
|
178
|
+
if (method === 'FILES') {
|
|
179
|
+
if (Array.isArray(value)) {
|
|
180
|
+
files = value
|
|
181
|
+
} else if (value) {
|
|
182
|
+
files = [value]
|
|
183
|
+
}
|
|
184
|
+
// Implicit server-limit guard: truncated files always fail
|
|
185
|
+
if (files.some(f => f.truncated)) {
|
|
186
|
+
error = true
|
|
187
|
+
this.#message[key] = checkItem.message
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
176
192
|
if (typeof rules === 'boolean') {
|
|
177
193
|
error = rules === false
|
|
178
194
|
} else {
|
|
179
195
|
for (const rule of rules.includes('|') ? rules.split('|') : [rules]) {
|
|
180
|
-
let vars = rule.split(
|
|
196
|
+
let vars = rule.split(/:(.+)/)
|
|
181
197
|
let ruleName = vars[0].trim()
|
|
182
198
|
let inverse = ruleName.startsWith('!')
|
|
183
199
|
if (inverse) ruleName = ruleName.substr(1)
|
|
@@ -185,7 +201,11 @@ class Validator {
|
|
|
185
201
|
if (!error) {
|
|
186
202
|
switch (ruleName) {
|
|
187
203
|
case 'required':
|
|
188
|
-
|
|
204
|
+
if (method === 'FILES') {
|
|
205
|
+
error = files.length === 0
|
|
206
|
+
} else {
|
|
207
|
+
error = value === undefined || value === '' || value === null
|
|
208
|
+
}
|
|
189
209
|
break
|
|
190
210
|
case 'accepted':
|
|
191
211
|
error = !value || (value !== 1 && value !== '1' && value !== 'on' && value !== 'yes' && value !== true)
|
|
@@ -300,6 +320,35 @@ class Validator {
|
|
|
300
320
|
case 'disposable':
|
|
301
321
|
error = value && value !== '' && !(await Validator.isDisposable(value))
|
|
302
322
|
break
|
|
323
|
+
case 'maxsize':
|
|
324
|
+
if (method === 'FILES' && vars[1]) {
|
|
325
|
+
const maxBytes = this.#parseSize(vars[1])
|
|
326
|
+
error = files.some(f => f.size > maxBytes)
|
|
327
|
+
}
|
|
328
|
+
break
|
|
329
|
+
case 'minsize':
|
|
330
|
+
if (method === 'FILES' && vars[1]) {
|
|
331
|
+
const minBytes = this.#parseSize(vars[1])
|
|
332
|
+
error = files.some(f => f.size < minBytes)
|
|
333
|
+
}
|
|
334
|
+
break
|
|
335
|
+
case 'mimetype':
|
|
336
|
+
case 'accept':
|
|
337
|
+
if (method === 'FILES' && vars[1] && files.length > 0) {
|
|
338
|
+
error = !(await this.#validateMimetype(files, vars[1]))
|
|
339
|
+
}
|
|
340
|
+
break
|
|
341
|
+
case 'ext':
|
|
342
|
+
if (method === 'FILES' && vars[1] && files.length > 0) {
|
|
343
|
+
const allowedExts = vars[1].split(',').map(e => e.trim().toLowerCase())
|
|
344
|
+
error = !files.every(f => allowedExts.includes(f.ext))
|
|
345
|
+
}
|
|
346
|
+
break
|
|
347
|
+
case 'maxfiles':
|
|
348
|
+
if (method === 'FILES' && vars[1]) {
|
|
349
|
+
error = files.length > parseInt(vars[1])
|
|
350
|
+
}
|
|
351
|
+
break
|
|
303
352
|
}
|
|
304
353
|
if (inverse) error = !error
|
|
305
354
|
}
|
|
@@ -316,6 +365,98 @@ class Validator {
|
|
|
316
365
|
this.#completed = true
|
|
317
366
|
}
|
|
318
367
|
|
|
368
|
+
#parseSize(sizeStr) {
|
|
369
|
+
const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i)
|
|
370
|
+
if (!match) return 0
|
|
371
|
+
let bytes = parseFloat(match[1])
|
|
372
|
+
const unit = (match[2] || 'B').toUpperCase()
|
|
373
|
+
const multipliers = {B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024}
|
|
374
|
+
return Math.floor(bytes * (multipliers[unit] || 1))
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async #validateMimetype(files, mimeStr) {
|
|
378
|
+
const allowedTypes = mimeStr.split(',').map(m => m.trim().toLowerCase())
|
|
379
|
+
const extToMimeMap = {
|
|
380
|
+
jpg: 'image/jpeg',
|
|
381
|
+
jpeg: 'image/jpeg',
|
|
382
|
+
png: 'image/png',
|
|
383
|
+
gif: 'image/gif',
|
|
384
|
+
webp: 'image/webp',
|
|
385
|
+
svg: 'image/svg+xml',
|
|
386
|
+
pdf: 'application/pdf',
|
|
387
|
+
txt: 'text/plain',
|
|
388
|
+
csv: 'text/csv',
|
|
389
|
+
zip: 'application/zip',
|
|
390
|
+
mp4: 'video/mp4',
|
|
391
|
+
mp3: 'audio/mpeg',
|
|
392
|
+
doc: 'application/msword',
|
|
393
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
394
|
+
xls: 'application/vnd.ms-excel',
|
|
395
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
396
|
+
}
|
|
397
|
+
for (const file of files) {
|
|
398
|
+
const claimed = (file.mimetype || '').toLowerCase()
|
|
399
|
+
const extMime = extToMimeMap[file.ext] || 'application/octet-stream'
|
|
400
|
+
let allowed = false
|
|
401
|
+
for (const type of allowedTypes) {
|
|
402
|
+
if (type === claimed || type === extMime) {
|
|
403
|
+
allowed = true
|
|
404
|
+
break
|
|
405
|
+
}
|
|
406
|
+
if (type.endsWith('/*')) {
|
|
407
|
+
const prefix = type.slice(0, -2)
|
|
408
|
+
if (claimed.startsWith(prefix) || extMime.startsWith(prefix)) {
|
|
409
|
+
allowed = true
|
|
410
|
+
break
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (!allowed) return false
|
|
415
|
+
|
|
416
|
+
// Content sniffing: if the file's own extension is a raster format we have
|
|
417
|
+
// a signature for, verify the bytes match to catch a renamed/spoofed file
|
|
418
|
+
// (e.g. a script uploaded as photo.png). Formats without a known signature
|
|
419
|
+
// (svg, pdf, office docs, ...) fall back to extension + claimed MIME.
|
|
420
|
+
if (file.path) {
|
|
421
|
+
const sniff = await this.#sniffImage(file.path, file.ext)
|
|
422
|
+
if (sniff === false) return false
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return true
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Returns true if the file's leading bytes match the expected signature for
|
|
429
|
+
// its extension, false if it's a known raster type but the bytes don't match,
|
|
430
|
+
// and null if the extension has no signature we sniff (caller should skip).
|
|
431
|
+
async #sniffImage(filePath, ext) {
|
|
432
|
+
const format = ext === 'jpg' ? 'jpeg' : ext
|
|
433
|
+
if (!['jpeg', 'png', 'gif', 'webp'].includes(format)) return null
|
|
434
|
+
|
|
435
|
+
let buf
|
|
436
|
+
let fd
|
|
437
|
+
try {
|
|
438
|
+
fd = await require('fs').promises.open(filePath, 'r')
|
|
439
|
+
buf = Buffer.alloc(12)
|
|
440
|
+
await fd.read(buf, 0, 12, 0)
|
|
441
|
+
} catch {
|
|
442
|
+
return null // unreadable: don't fail validation on a sniff error
|
|
443
|
+
} finally {
|
|
444
|
+
if (fd) await fd.close().catch(() => {})
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
switch (format) {
|
|
448
|
+
case 'jpeg':
|
|
449
|
+
return buf.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))
|
|
450
|
+
case 'png':
|
|
451
|
+
return buf.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47]))
|
|
452
|
+
case 'gif':
|
|
453
|
+
return buf.subarray(0, 4).equals(Buffer.from([0x47, 0x49, 0x46, 0x38]))
|
|
454
|
+
case 'webp':
|
|
455
|
+
// RIFF container + "WEBP" fourCC at offset 8 (distinguishes from AVI/WAV)
|
|
456
|
+
return buf.subarray(0, 4).toString('latin1') === 'RIFF' && buf.subarray(8, 12).toString('latin1') === 'WEBP'
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
319
460
|
static async isDisposable(email) {
|
|
320
461
|
if (!email || typeof email !== 'string') return false
|
|
321
462
|
await loadDisposableDomains()
|
package/src/View/Form.js
CHANGED
|
@@ -173,6 +173,7 @@ class Form {
|
|
|
173
173
|
attrs += ` novalidate`
|
|
174
174
|
if (config.id) attrs += ` id="${this.escapeHtml(config.id)}"`
|
|
175
175
|
if (type === 'form' && config.clear !== undefined) attrs += ` clear="${config.clear}"`
|
|
176
|
+
if (config.fields && config.fields.some(f => f.type === 'file')) attrs += ` enctype="multipart/form-data"`
|
|
176
177
|
|
|
177
178
|
let html = `<form ${attrs}>\n`
|
|
178
179
|
html += ` <input type="hidden" name="${meta.tokenInputName}" value="${this.escapeHtml(token)}">\n`
|
|
@@ -537,6 +538,9 @@ class Form {
|
|
|
537
538
|
html += `<textarea${idAttr} name="${escapedName}" placeholder="${escapedPlaceholder}"${classAttr}${attrs}>${this.escapeHtmlPreservingTemplates(
|
|
538
539
|
field.value || ''
|
|
539
540
|
)}</textarea>\n`
|
|
541
|
+
} else if (field.type === 'file') {
|
|
542
|
+
const attrs = this.buildHtml5Attributes(field)
|
|
543
|
+
html += `<input type="file"${idAttr} name="${escapedName}"${classAttr}${attrs}>\n`
|
|
540
544
|
} else {
|
|
541
545
|
const attrs = this.buildHtml5Attributes(field)
|
|
542
546
|
html += `<input type="${escapedType}"${idAttr} name="${escapedName}"${valueAttr} placeholder="${escapedPlaceholder}"${classAttr}${attrs}>\n`
|
|
@@ -545,6 +549,15 @@ class Form {
|
|
|
545
549
|
return html
|
|
546
550
|
}
|
|
547
551
|
|
|
552
|
+
static #parseSizeStatic(sizeStr) {
|
|
553
|
+
const match = sizeStr.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)?$/i)
|
|
554
|
+
if (!match) return 0
|
|
555
|
+
let bytes = parseFloat(match[1])
|
|
556
|
+
const unit = (match[2] || 'B').toUpperCase()
|
|
557
|
+
const multipliers = {B: 1, KB: 1024, MB: 1024 * 1024, GB: 1024 * 1024 * 1024}
|
|
558
|
+
return Math.floor(bytes * (multipliers[unit] || 1))
|
|
559
|
+
}
|
|
560
|
+
|
|
548
561
|
static appendExtraAttributes(attrs, field) {
|
|
549
562
|
if (!field.extraAttributes) return attrs
|
|
550
563
|
for (const key in field.extraAttributes) {
|
|
@@ -624,6 +637,45 @@ class Form {
|
|
|
624
637
|
if (validation.message) errorMessages.required = validation.message
|
|
625
638
|
}
|
|
626
639
|
break
|
|
640
|
+
case 'maxsize':
|
|
641
|
+
if (field.type === 'file') {
|
|
642
|
+
const maxBytes = this.#parseSizeStatic(ruleValue)
|
|
643
|
+
attrs += ` data-maxsize="${maxBytes}"`
|
|
644
|
+
if (validation.message) errorMessages.maxsize = validation.message
|
|
645
|
+
}
|
|
646
|
+
break
|
|
647
|
+
case 'minsize':
|
|
648
|
+
if (field.type === 'file') {
|
|
649
|
+
const minBytes = this.#parseSizeStatic(ruleValue)
|
|
650
|
+
attrs += ` data-minsize="${minBytes}"`
|
|
651
|
+
if (validation.message) errorMessages.minsize = validation.message
|
|
652
|
+
}
|
|
653
|
+
break
|
|
654
|
+
case 'mimetype':
|
|
655
|
+
case 'accept':
|
|
656
|
+
if (field.type === 'file') {
|
|
657
|
+
attrs += ` accept="${this.escapeHtml(ruleValue)}"`
|
|
658
|
+
if (validation.message) errorMessages.accept = validation.message
|
|
659
|
+
}
|
|
660
|
+
break
|
|
661
|
+
case 'ext':
|
|
662
|
+
if (field.type === 'file') {
|
|
663
|
+
const exts = ruleValue
|
|
664
|
+
.split(',')
|
|
665
|
+
.map(e => e.trim())
|
|
666
|
+
.map(e => (e.startsWith('.') ? e : '.' + e))
|
|
667
|
+
.join(',')
|
|
668
|
+
attrs += ` accept="${this.escapeHtml(exts)}"`
|
|
669
|
+
if (validation.message) errorMessages.accept = validation.message
|
|
670
|
+
}
|
|
671
|
+
break
|
|
672
|
+
case 'maxfiles':
|
|
673
|
+
if (field.type === 'file' && parseInt(ruleValue) > 1) {
|
|
674
|
+
attrs += ` multiple`
|
|
675
|
+
attrs += ` data-maxfiles="${this.escapeHtml(ruleValue)}"`
|
|
676
|
+
if (validation.message) errorMessages.maxfiles = validation.message
|
|
677
|
+
}
|
|
678
|
+
break
|
|
627
679
|
}
|
|
628
680
|
}
|
|
629
681
|
}
|
|
@@ -640,6 +692,10 @@ class Form {
|
|
|
640
692
|
if (errorMessages.maxlength) attrs += ` data-error-maxlength="${this.escapeHtml(errorMessages.maxlength)}"`
|
|
641
693
|
if (errorMessages.pattern) attrs += ` data-error-pattern="${this.escapeHtml(errorMessages.pattern)}"`
|
|
642
694
|
if (errorMessages.email) attrs += ` data-error-email="${this.escapeHtml(errorMessages.email)}"`
|
|
695
|
+
if (errorMessages.maxsize) attrs += ` data-error-maxsize="${this.escapeHtml(errorMessages.maxsize)}"`
|
|
696
|
+
if (errorMessages.minsize) attrs += ` data-error-minsize="${this.escapeHtml(errorMessages.minsize)}"`
|
|
697
|
+
if (errorMessages.accept) attrs += ` data-error-accept="${this.escapeHtml(errorMessages.accept)}"`
|
|
698
|
+
if (errorMessages.maxfiles) attrs += ` data-error-maxfiles="${this.escapeHtml(errorMessages.maxfiles)}"`
|
|
643
699
|
|
|
644
700
|
return this.appendExtraAttributes(attrs, field)
|
|
645
701
|
}
|