Skip to content

Commit cc415b6

Browse files
taylortomclaude
andauthored
Update: extract validateUploadedFiles into individual utility file (fixes #78) (#79)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ca3c9c2 commit cc415b6

File tree

5 files changed

+185
-27
lines changed

5 files changed

+185
-27
lines changed

lib/MiddlewareModule.js

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import bodyParser from 'body-parser'
44
import bytes from 'bytes'
55
import compression from 'compression'
66
import { createWriteStream } from 'fs'
7-
import { fileTypeFromFile } from 'file-type'
87
import formidable from 'formidable'
98
import fs from 'fs/promises'
109
import path from 'path'
1110
import helmet from 'helmet'
1211
import { RateLimiterMongo } from 'rate-limiter-flexible'
1312
import { unzip } from 'zipper'
13+
import { validateUploadedFiles } from './utils/validateUploadedFiles.js'
1414
/**
1515
* Adds useful Express middleware to the server stack
1616
* @memberof middleware
@@ -253,30 +253,5 @@ class MiddlewareModule extends AbstractModule {
253253
}
254254
}
255255
}
256-
/** @ignore */
257-
async function validateUploadedFiles (req, filesObj, options) {
258-
const errors = App.instance.errors
259-
const assetErrors = []
260-
const filesArr = Object.values(filesObj).reduce((memo, f) => memo.concat(f), []) // flatten nested arrays
261-
await Promise.all(filesArr.map(async f => {
262-
if (!options.expectedFileTypes.includes(f.mimetype)) {
263-
// formidable mimetype isn't allowed, try inspecting the file
264-
f.mimetype = (await fileTypeFromFile(f.filepath))?.mime
265-
if (!f.mimetype && path.extname(f.originalFilename) === '.srt') {
266-
f.mimetype = 'application/x-subrip'
267-
}
268-
if (!options.expectedFileTypes.includes(f.mimetype)) {
269-
assetErrors.push(errors.UNEXPECTED_FILE_TYPES.setData({ expectedFileTypes: options.expectedFileTypes, invalidFiles: [f.originalFilename], mimetypes: [f.mimetype] }))
270-
}
271-
}
272-
if (f.size > options.maxFileSize) {
273-
assetErrors.push(errors.FILE_EXCEEDS_MAX_SIZE.setData({ size: bytes(f.size), maxSize: bytes(options.maxFileSize) }))
274-
}
275-
}))
276-
if (assetErrors.length) {
277-
throw errors.VALIDATION_FAILED
278-
.setData({ schemaName: 'fileupload', errors: assetErrors.map(req.translate).join(', ') })
279-
}
280-
}
281256

282257
export default MiddlewareModule

lib/utils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { validateUploadedFiles } from './utils/validateUploadedFiles.js'

lib/utils/validateUploadedFiles.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { App } from 'adapt-authoring-core'
2+
import bytes from 'bytes'
3+
import { fileTypeFromFile } from 'file-type'
4+
import path from 'path'
5+
6+
/**
7+
* Validates uploaded files against expected types and size limits
8+
* @param {external:ExpressRequest} req
9+
* @param {Object} filesObj Files object from formidable
10+
* @param {Object} options Upload options including expectedFileTypes and maxFileSize
11+
* @memberof middleware
12+
*/
13+
export async function validateUploadedFiles (req, filesObj, options) {
14+
const errors = App.instance.errors
15+
const assetErrors = []
16+
const filesArr = Object.values(filesObj).reduce((memo, f) => memo.concat(f), []) // flatten nested arrays
17+
await Promise.all(filesArr.map(async f => {
18+
if (!options.expectedFileTypes.includes(f.mimetype)) {
19+
// formidable mimetype isn't allowed, try inspecting the file
20+
f.mimetype = (await fileTypeFromFile(f.filepath))?.mime
21+
if (!f.mimetype && path.extname(f.originalFilename) === '.srt') {
22+
f.mimetype = 'application/x-subrip'
23+
}
24+
if (!options.expectedFileTypes.includes(f.mimetype)) {
25+
assetErrors.push(errors.UNEXPECTED_FILE_TYPES.setData({ expectedFileTypes: options.expectedFileTypes, invalidFiles: [f.originalFilename], mimetypes: [f.mimetype] }))
26+
}
27+
}
28+
if (f.size > options.maxFileSize) {
29+
assetErrors.push(errors.FILE_EXCEEDS_MAX_SIZE.setData({ size: bytes(f.size), maxSize: bytes(options.maxFileSize) }))
30+
}
31+
}))
32+
if (assetErrors.length) {
33+
throw errors.VALIDATION_FAILED
34+
.setData({ schemaName: 'fileupload', errors: assetErrors.map(req.translate).join(', ') })
35+
}
36+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"zipper": "github:adapt-security/zipper"
2121
},
2222
"peerDependencies": {
23-
"adapt-authoring-core": "^1.7.0",
23+
"adapt-authoring-core": "^2.0.0",
2424
"adapt-authoring-mongodb": "^1.1.3",
2525
"adapt-authoring-server": "^1.2.1"
2626
},
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, it, mock, before, after } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import fs from 'fs/promises'
4+
import path from 'path'
5+
import os from 'os'
6+
import App from 'adapt-authoring-core/lib/App.js'
7+
8+
mock.getter(App, 'instance', () => ({
9+
errors: {
10+
UNEXPECTED_FILE_TYPES: {
11+
setData (data) {
12+
const e = new Error('UNEXPECTED_FILE_TYPES')
13+
e.code = 'UNEXPECTED_FILE_TYPES'
14+
e.data = data
15+
return e
16+
}
17+
},
18+
FILE_EXCEEDS_MAX_SIZE: {
19+
setData (data) {
20+
const e = new Error('FILE_EXCEEDS_MAX_SIZE')
21+
e.code = 'FILE_EXCEEDS_MAX_SIZE'
22+
e.data = data
23+
return e
24+
}
25+
},
26+
VALIDATION_FAILED: {
27+
setData (data) {
28+
const e = new Error('VALIDATION_FAILED')
29+
e.code = 'VALIDATION_FAILED'
30+
e.data = data
31+
return e
32+
}
33+
}
34+
}
35+
}))
36+
37+
const { validateUploadedFiles } = await import('../lib/utils/validateUploadedFiles.js')
38+
39+
describe('validateUploadedFiles()', () => {
40+
const makeReq = () => ({ translate: (e) => e.message || String(e) })
41+
let tmpDir
42+
43+
before(async () => {
44+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'middleware-test-'))
45+
// Create test files
46+
await fs.writeFile(path.join(tmpDir, 'test.txt'), 'hello world')
47+
await fs.writeFile(path.join(tmpDir, 'subtitle.srt'), '1\n00:00:00,000 --> 00:00:01,000\nHello')
48+
})
49+
50+
after(async () => {
51+
await fs.rm(tmpDir, { recursive: true })
52+
})
53+
54+
it('should pass when file matches expected type', async () => {
55+
const req = makeReq()
56+
const files = { file: [{ mimetype: 'image/png', originalFilename: 'test.png', size: 100, filepath: path.join(tmpDir, 'test.txt') }] }
57+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
58+
await assert.doesNotReject(() => validateUploadedFiles(req, files, options))
59+
})
60+
61+
it('should pass with multiple files all matching expected types', async () => {
62+
const req = makeReq()
63+
const files = {
64+
file1: [{ mimetype: 'image/png', originalFilename: 'a.png', size: 100, filepath: path.join(tmpDir, 'test.txt') }],
65+
file2: [{ mimetype: 'image/jpeg', originalFilename: 'b.jpg', size: 200, filepath: path.join(tmpDir, 'test.txt') }]
66+
}
67+
const options = { expectedFileTypes: ['image/png', 'image/jpeg'], maxFileSize: 1000 }
68+
await assert.doesNotReject(() => validateUploadedFiles(req, files, options))
69+
})
70+
71+
it('should throw VALIDATION_FAILED when file exceeds max size', async () => {
72+
const req = makeReq()
73+
const files = { file: [{ mimetype: 'image/png', originalFilename: 'big.png', size: 5000, filepath: path.join(tmpDir, 'test.txt') }] }
74+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
75+
await assert.rejects(
76+
() => validateUploadedFiles(req, files, options),
77+
(err) => err.code === 'VALIDATION_FAILED'
78+
)
79+
})
80+
81+
it('should throw VALIDATION_FAILED for unexpected file type', async () => {
82+
// fileTypeFromFile returns null for plain text files, so mimetype stays unmatched
83+
const req = makeReq()
84+
const files = { file: [{ mimetype: 'text/plain', originalFilename: 'test.txt', size: 100, filepath: path.join(tmpDir, 'test.txt') }] }
85+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
86+
await assert.rejects(
87+
() => validateUploadedFiles(req, files, options),
88+
(err) => err.code === 'VALIDATION_FAILED'
89+
)
90+
})
91+
92+
it('should treat .srt files as application/x-subrip when file inspection returns null', async () => {
93+
const req = makeReq()
94+
const files = { file: [{ mimetype: 'text/plain', originalFilename: 'subtitle.srt', size: 100, filepath: path.join(tmpDir, 'subtitle.srt') }] }
95+
const options = { expectedFileTypes: ['application/x-subrip'], maxFileSize: 1000 }
96+
await assert.doesNotReject(() => validateUploadedFiles(req, files, options))
97+
})
98+
99+
it('should handle empty files object', async () => {
100+
const req = makeReq()
101+
const files = {}
102+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
103+
await assert.doesNotReject(() => validateUploadedFiles(req, files, options))
104+
})
105+
106+
it('should flatten nested file arrays', async () => {
107+
const req = makeReq()
108+
const files = {
109+
images: [
110+
{ mimetype: 'image/png', originalFilename: 'a.png', size: 100, filepath: path.join(tmpDir, 'test.txt') },
111+
{ mimetype: 'image/png', originalFilename: 'b.png', size: 200, filepath: path.join(tmpDir, 'test.txt') }
112+
]
113+
}
114+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
115+
await assert.doesNotReject(() => validateUploadedFiles(req, files, options))
116+
})
117+
118+
it('should include both type and size errors in VALIDATION_FAILED', async () => {
119+
const req = makeReq()
120+
const files = { file: [{ mimetype: 'text/plain', originalFilename: 'test.txt', size: 5000, filepath: path.join(tmpDir, 'test.txt') }] }
121+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
122+
await assert.rejects(
123+
() => validateUploadedFiles(req, files, options),
124+
(err) => {
125+
return err.code === 'VALIDATION_FAILED' && err.data.schemaName === 'fileupload'
126+
}
127+
)
128+
})
129+
130+
it('should pass when file size equals max size', async () => {
131+
const req = makeReq()
132+
const files = { file: [{ mimetype: 'image/png', originalFilename: 'exact.png', size: 1000, filepath: path.join(tmpDir, 'test.txt') }] }
133+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
134+
await assert.doesNotReject(() => validateUploadedFiles(req, files, options))
135+
})
136+
137+
it('should fail when file size is one byte over max', async () => {
138+
const req = makeReq()
139+
const files = { file: [{ mimetype: 'image/png', originalFilename: 'over.png', size: 1001, filepath: path.join(tmpDir, 'test.txt') }] }
140+
const options = { expectedFileTypes: ['image/png'], maxFileSize: 1000 }
141+
await assert.rejects(
142+
() => validateUploadedFiles(req, files, options),
143+
(err) => err.code === 'VALIDATION_FAILED'
144+
)
145+
})
146+
})

0 commit comments

Comments
 (0)