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.
Files changed (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/odac.js +1 -0
  3. package/client/odac.js +57 -1
  4. package/docs/ai/skills/backend/forms.md +54 -3
  5. package/docs/ai/skills/backend/validation.md +30 -2
  6. package/docs/ai/skills/frontend/core.md +5 -5
  7. package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +1 -1
  8. package/docs/backend/05-forms/01-custom-forms.md +96 -0
  9. package/docs/backend/09-validation/01-the-validator-service.md +40 -0
  10. package/docs/frontend/01-overview/01-introduction.md +8 -8
  11. package/docs/frontend/02-ajax-navigation/01-quick-start.md +14 -14
  12. package/docs/frontend/02-ajax-navigation/02-configuration.md +4 -4
  13. package/docs/frontend/02-ajax-navigation/03-advanced-usage.md +16 -16
  14. package/docs/frontend/03-forms/01-form-handling.md +84 -31
  15. package/docs/frontend/04-api-requests/01-get-post.md +25 -25
  16. package/docs/frontend/05-streaming/01-client-streaming.md +14 -14
  17. package/docs/frontend/06-websocket/00-overview.md +1 -1
  18. package/index.js +11 -1
  19. package/package.json +2 -1
  20. package/src/Auth.js +252 -24
  21. package/src/Config.js +5 -1
  22. package/src/Odac.js +3 -0
  23. package/src/Request.js +235 -34
  24. package/src/Route/Internal.js +32 -3
  25. package/src/Validator.js +143 -2
  26. package/src/View/Form.js +56 -0
  27. package/src/View/Image.js +15 -6
  28. package/src/View.js +2 -2
  29. package/test/Auth/check.test.js +219 -18
  30. package/test/Auth/verifyMagicLink.test.js +8 -1
  31. package/test/Odac/cache.test.js +5 -2
  32. package/test/Odac/image.test.js +8 -5
  33. package/test/Odac/instance.test.js +5 -2
  34. package/test/Request/cache.test.js +1 -0
  35. package/test/Request/multipart.test.js +164 -0
  36. package/test/Validator/file.test.js +172 -0
  37. package/test/View/Form/generateFieldHtml.test.js +56 -0
  38. package/test/View/Image/serve.test.js +9 -4
  39. package/test/View/Image/url.test.js +10 -4
  40. package/test/View/addNavigateAttribute.test.js +10 -1
  41. 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
- if (typeof this.#odac.setTimeout === 'function') {
40
- this.#timeout = this.#odac.setTimeout(() => !this.res.finished && this.abort(408), global.Odac.Config.request.timeout)
41
- } else {
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
- if (body.length > 1e6) {
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
- } else {
111
- if (body.length > 0 && body.indexOf('Content-Disposition') === -1) return
112
- if (body.indexOf('Content-Disposition') > -1) {
113
- let boundary = body.split('\r\n')[0]
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
- this.data.post = JSON.parse(body)
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')
@@ -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
- if (!field.skip) {
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
- error = value === undefined || value === '' || value === null
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
  }