adapt-authoring-middleware 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,55 +1,55 @@
1
- name: Bug Report
2
- description: File a bug report
3
- labels: ["bug"]
4
- body:
5
- - type: markdown
6
- attributes:
7
- value: |
8
- Thanks for taking the time to fill out this bug report!
9
- - type: textarea
10
- id: description
11
- attributes:
12
- label: What happened?
13
- description: Please describe the issue
14
- validations:
15
- required: true
16
- - type: textarea
17
- id: expected
18
- attributes:
19
- label: Expected behaviour
20
- description: Tell us what should have happened
21
- - type: textarea
22
- id: repro-steps
23
- attributes:
24
- label: Steps to reproduce
25
- description: Tell us how to reproduce the issue
26
- validations:
27
- required: true
28
- - type: input
29
- id: aat-version
30
- attributes:
31
- label: Authoring tool version
32
- description: What version of the Adapt authoring tool are you running?
33
- validations:
34
- required: true
35
- - type: input
36
- id: fw-version
37
- attributes:
38
- label: Framework version
39
- description: What version of the Adapt framework are you running?
40
- - type: dropdown
41
- id: browsers
42
- attributes:
43
- label: What browsers are you seeing the problem on?
44
- multiple: true
45
- options:
46
- - Firefox
47
- - Chrome
48
- - Safari
49
- - Microsoft Edge
50
- - type: textarea
51
- id: logs
52
- attributes:
53
- label: Relevant log output
54
- description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
- render: sh
1
+ name: Bug Report
2
+ description: File a bug report
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to fill out this bug report!
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: What happened?
13
+ description: Please describe the issue
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: expected
18
+ attributes:
19
+ label: Expected behaviour
20
+ description: Tell us what should have happened
21
+ - type: textarea
22
+ id: repro-steps
23
+ attributes:
24
+ label: Steps to reproduce
25
+ description: Tell us how to reproduce the issue
26
+ validations:
27
+ required: true
28
+ - type: input
29
+ id: aat-version
30
+ attributes:
31
+ label: Authoring tool version
32
+ description: What version of the Adapt authoring tool are you running?
33
+ validations:
34
+ required: true
35
+ - type: input
36
+ id: fw-version
37
+ attributes:
38
+ label: Framework version
39
+ description: What version of the Adapt framework are you running?
40
+ - type: dropdown
41
+ id: browsers
42
+ attributes:
43
+ label: What browsers are you seeing the problem on?
44
+ multiple: true
45
+ options:
46
+ - Firefox
47
+ - Chrome
48
+ - Safari
49
+ - Microsoft Edge
50
+ - type: textarea
51
+ id: logs
52
+ attributes:
53
+ label: Relevant log output
54
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
+ render: sh
@@ -1,22 +1,22 @@
1
- name: Feature request
2
- description: Request a new feature
3
- labels: ["enhancement"]
4
- body:
5
- - type: markdown
6
- attributes:
7
- value: |
8
- Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
- - type: textarea
10
- id: description
11
- attributes:
12
- label: Feature description
13
- description: Please describe your feature request
14
- validations:
15
- required: true
16
- - type: checkboxes
17
- id: contribute
18
- attributes:
19
- label: Can you work on this feature?
20
- description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
- options:
22
- - label: I can contribute
1
+ name: Feature request
2
+ description: Request a new feature
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: Feature description
13
+ description: Please describe your feature request
14
+ validations:
15
+ required: true
16
+ - type: checkboxes
17
+ id: contribute
18
+ attributes:
19
+ label: Can you work on this feature?
20
+ description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
+ options:
22
+ - label: I can contribute
@@ -1,11 +1,11 @@
1
- # To get started with Dependabot version updates, you'll need to specify which
2
- # package ecosystems to update and where the package manifests are located.
3
- # Please see the documentation for all configuration options:
4
- # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
-
6
- version: 2
7
- updates:
8
- - package-ecosystem: "npm" # See documentation for possible values
9
- directory: "/" # Location of package manifests
10
- schedule:
11
- interval: "weekly"
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -1,25 +1,25 @@
1
- [//]: # (Please title your PR according to eslint commit conventions)
2
- [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
-
4
- [//]: # (Add a link to the original issue)
5
-
6
- [//]: # (Delete as appropriate)
7
- ### Fix
8
- * A sentence describing each fix
9
-
10
- ### Update
11
- * A sentence describing each udpate
12
-
13
- ### New
14
- * A sentence describing each new feature
15
-
16
- ### Breaking
17
- * A sentence describing each breaking change
18
-
19
- [//]: # (List appropriate steps for testing if needed)
20
- ### Testing
21
- 1. Steps for testing
22
-
23
- [//]: # (Mention any other dependencies)
24
-
25
-
1
+ [//]: # (Please title your PR according to eslint commit conventions)
2
+ [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
+
4
+ [//]: # (Add a link to the original issue)
5
+
6
+ [//]: # (Delete as appropriate)
7
+ ### Fix
8
+ * A sentence describing each fix
9
+
10
+ ### Update
11
+ * A sentence describing each udpate
12
+
13
+ ### New
14
+ * A sentence describing each new feature
15
+
16
+ ### Breaking
17
+ * A sentence describing each breaking change
18
+
19
+ [//]: # (List appropriate steps for testing if needed)
20
+ ### Testing
21
+ 1. Steps for testing
22
+
23
+ [//]: # (Mention any other dependencies)
24
+
25
+
@@ -1,18 +1,18 @@
1
- name: Label PRs to allow add-to-project to run
2
-
3
- permissions:
4
- pull-requests: write
5
-
6
- on:
7
- schedule:
8
- - cron: '0 6 * * *'
9
-
10
- jobs:
11
- add-to-project-label:
12
- name: Add label after a day
13
- runs-on: ubuntu-latest
14
- steps:
15
- - uses: actions/stale@v9
16
- with:
17
- days-before-stale: 1
18
- stale-pr-label: 'sorted'
1
+ name: Label PRs to allow add-to-project to run
2
+
3
+ permissions:
4
+ pull-requests: write
5
+
6
+ on:
7
+ schedule:
8
+ - cron: '0 6 * * *'
9
+
10
+ jobs:
11
+ add-to-project-label:
12
+ name: Add label after a day
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/stale@v9
16
+ with:
17
+ days-before-stale: 1
18
+ stale-pr-label: 'sorted'
@@ -1,16 +1,16 @@
1
- name: Add labelled PRs to project
2
-
3
- on:
4
- pull_request:
5
- types: [ labeled ]
6
-
7
- jobs:
8
- add-to-project:
9
- if: ${{ github.event.label.name == 'dependencies' }}
10
- name: Add to main project
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/add-to-project@v0.1.0
14
- with:
15
- project-url: https://github.com/orgs/adapt-security/projects/5
16
- github-token: ${{ secrets.PROJECTS_SECRET }}
1
+ name: Add labelled PRs to project
2
+
3
+ on:
4
+ pull_request:
5
+ types: [ labeled ]
6
+
7
+ jobs:
8
+ add-to-project:
9
+ if: ${{ github.event.label.name == 'dependencies' }}
10
+ name: Add to main project
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/add-to-project@v0.1.0
14
+ with:
15
+ project-url: https://github.com/orgs/adapt-security/projects/5
16
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -1,19 +1,19 @@
1
- name: Add to main project
2
-
3
- on:
4
- issues:
5
- types:
6
- - opened
7
- pull_request:
8
- types:
9
- - opened
10
-
11
- jobs:
12
- add-to-project:
13
- name: Add to main project
14
- runs-on: ubuntu-latest
15
- steps:
16
- - uses: actions/add-to-project@v0.1.0
17
- with:
18
- project-url: https://github.com/orgs/adapt-security/projects/5
19
- github-token: ${{ secrets.PROJECTS_SECRET }}
1
+ name: Add to main project
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ name: Add to main project
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/add-to-project@v0.1.0
17
+ with:
18
+ project-url: https://github.com/orgs/adapt-security/projects/5
19
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,25 @@
1
+ name: Release
2
+ on:
3
+ push:
4
+ branches:
5
+ - master
6
+ jobs:
7
+ release:
8
+ name: Release
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout
12
+ uses: actions/checkout@v3
13
+ with:
14
+ fetch-depth: 0
15
+ - name: Setup Node.js
16
+ uses: actions/setup-node@v3
17
+ with:
18
+ node-version: 'lts/*'
19
+ - name: Install dependencies
20
+ run: npm ci
21
+ - name: Release
22
+ env:
23
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24
+ NPM_TOKEN: ${{ secrets.AAT_NPM_TOKEN }}
25
+ run: npx semantic-release
@@ -0,0 +1,13 @@
1
+ name: Standard.js formatting check
2
+ on: push
3
+ jobs:
4
+ default:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@master
8
+ - uses: actions/setup-node@master
9
+ with:
10
+ node-version: 'lts/*'
11
+ cache: 'npm'
12
+ - run: npm ci
13
+ - run: npx standard
@@ -1,5 +1,5 @@
1
- {
2
- "documentation": {
3
- "enable": true
4
- }
5
- }
1
+ {
2
+ "documentation": {
3
+ "enable": true
4
+ }
5
+ }
@@ -1,38 +1,38 @@
1
- {
2
- "$schema": "https://json-schema.org/draft/2020-12/schema",
3
- "type": "object",
4
- "properties": {
5
- "acceptedTypes": {
6
- "description": "Content types the API accepts (may use MIME types or extension names)",
7
- "type": "array",
8
- "items": { "type": "string" },
9
- "default": ["application/json"]
10
- },
11
- "apiRequestLimit": {
12
- "description": "The number of API requests allowed by a single IP within the specified time limit",
13
- "type": "number",
14
- "default": 50
15
- },
16
- "apiRequestLimitDuration": {
17
- "description": "Amount of time before the request count is reset",
18
- "type": "string",
19
- "isTimeMs": true,
20
- "default": "1s"
21
- },
22
- "fileUploadMaxFileSize": {
23
- "description": "Default file size limit for uploaded files. Note that other modules may specify their own limits, please check full config documentation for details.",
24
- "type": "string",
25
- "isBytes": true,
26
- "default": "50mb",
27
- "_adapt": {
28
- "isPublic": true
29
- }
30
- },
31
- "uploadTempDir": {
32
- "description": "Temporary directory for file uploads",
33
- "type": "string",
34
- "isDirectory": true,
35
- "default": "$TEMP/file-uploads"
36
- }
37
- }
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "acceptedTypes": {
6
+ "description": "Content types the API accepts (may use MIME types or extension names)",
7
+ "type": "array",
8
+ "items": { "type": "string" },
9
+ "default": ["application/json"]
10
+ },
11
+ "apiRequestLimit": {
12
+ "description": "The number of API requests allowed by a single IP within the specified time limit",
13
+ "type": "number",
14
+ "default": 50
15
+ },
16
+ "apiRequestLimitDuration": {
17
+ "description": "Amount of time before the request count is reset",
18
+ "type": "string",
19
+ "isTimeMs": true,
20
+ "default": "1s"
21
+ },
22
+ "fileUploadMaxFileSize": {
23
+ "description": "Default file size limit for uploaded files. Note that other modules may specify their own limits, please check full config documentation for details.",
24
+ "type": "string",
25
+ "isBytes": true,
26
+ "default": "50mb",
27
+ "_adapt": {
28
+ "isPublic": true
29
+ }
30
+ },
31
+ "uploadTempDir": {
32
+ "description": "Temporary directory for file uploads",
33
+ "type": "string",
34
+ "isDirectory": true,
35
+ "default": "$TEMP/file-uploads"
36
+ }
37
+ }
38
38
  }
@@ -1,29 +1,29 @@
1
- {
2
- "BODY_PARSE_FAILED": {
3
- "data": {
4
- "error": "The error message"
5
- },
6
- "description": "Failed to parse request body data",
7
- "statusCode": 400
8
- },
9
- "FILE_EXCEEDS_MAX_SIZE": {
10
- "data": {
11
- "maxSize": "The maximum file size",
12
- "size": "Size of file"
13
- },
14
- "description": "Uploaded file exceeds the size limit",
15
- "statusCode": 413
16
- },
17
- "RATE_LIMIT_EXCEEDED": {
18
- "description": "API rate limit has been exceeded",
19
- "statusCode": 429
20
- },
21
- "UNEXPECTED_FILE_TYPES": {
22
- "data": {
23
- "expectedFileTypes": "The list of expected file types",
24
- "invalidFiles": "The list of invalid files"
25
- },
26
- "description": "Recieved unexpected file types",
27
- "statusCode": 400
28
- }
1
+ {
2
+ "BODY_PARSE_FAILED": {
3
+ "data": {
4
+ "error": "The error message"
5
+ },
6
+ "description": "Failed to parse request body data",
7
+ "statusCode": 400
8
+ },
9
+ "FILE_EXCEEDS_MAX_SIZE": {
10
+ "data": {
11
+ "maxSize": "The maximum file size",
12
+ "size": "Size of file"
13
+ },
14
+ "description": "Uploaded file exceeds the size limit",
15
+ "statusCode": 413
16
+ },
17
+ "RATE_LIMIT_EXCEEDED": {
18
+ "description": "API rate limit has been exceeded",
19
+ "statusCode": 429
20
+ },
21
+ "UNEXPECTED_FILE_TYPES": {
22
+ "data": {
23
+ "expectedFileTypes": "The list of expected file types",
24
+ "invalidFiles": "The list of invalid files"
25
+ },
26
+ "description": "Recieved unexpected file types",
27
+ "statusCode": 400
28
+ }
29
29
  }
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
- /**
2
- * Reusable Express.js middleware
3
- * @namespace middleware
4
- */
5
- export { default } from './lib/MiddlewareModule.js'
1
+ /**
2
+ * Reusable Express.js middleware
3
+ * @namespace middleware
4
+ */
5
+ export { default } from './lib/MiddlewareModule.js'
@@ -1,278 +1,281 @@
1
- import { AbstractModule, App } from 'adapt-authoring-core'
2
- import axios from 'axios'
3
- import bodyParser from 'body-parser'
4
- import bytes from 'bytes'
5
- import compression from 'compression'
6
- import { createWriteStream } from 'fs'
7
- import { fileTypeFromFile } from 'file-type'
8
- import formidable from 'formidable'
9
- import fs from 'fs/promises'
10
- import path from 'path'
11
- import helmet from 'helmet'
12
- import { RateLimiterMongo } from 'rate-limiter-flexible'
13
- import { unzip } from 'zipper'
14
- /**
15
- * Adds useful Express middleware to the server stack
16
- * @memberof middleware
17
- * @extends {AbstractModule}
18
- */
19
- class MiddlewareModule extends AbstractModule {
20
- get zipTypes () {
21
- return [
22
- 'application/zip',
23
- 'application/x-zip-compressed'
24
- ]
25
- }
26
-
27
- isZip (mimeType) {
28
- return this.zipTypes.includes(mimeType)
29
- }
30
-
31
- /** @override */
32
- async init () {
33
- const server = await this.app.waitForModule('server')
34
- const helmetFunc = helmet({
35
- xFrameOptions: false
36
- })
37
- // add custom middleware
38
- // server.root.addMiddleware(helmetFunc)
39
- server.api.addMiddleware(
40
- helmetFunc,
41
- this.rateLimiter(),
42
- this.bodyParserJson(),
43
- this.bodyParserUrlEncoded(),
44
- compression()
45
- )
46
- }
47
-
48
- /**
49
- * Limits how many requests indivual IPs can make
50
- * @return {Function} Express middleware function
51
- */
52
- async rateLimiter () {
53
- const [mongodb, server] = await this.app.waitForModule('mongodb', 'server')
54
- const { db } = await mongodb.getStats()
55
- const rateLimiter = new RateLimiterMongo({
56
- storeClient: mongodb.client,
57
- dbName: db,
58
- keyPrefix: 'ratelimiter',
59
- points: this.getConfig('apiRequestLimit'),
60
- duration: this.getConfig('apiRequestLimitDuration') / 1000
61
- })
62
- return async (req, res, next) => {
63
- try {
64
- const data = await rateLimiter.consume(req.ip)
65
- const resetAt = new Date(Date.now() + data.msBeforeNext)
66
- res.set({
67
- 'Retry-After': data.msBeforeNext / 1000,
68
- 'X-RateLimit-Limit': this.getConfig('apiRequestLimit'),
69
- 'X-RateLimit-Remaining': data.remainingPoints,
70
- 'X-RateLimit-Reset': resetAt
71
- })
72
- next()
73
- } catch (e) {
74
- res.sendError(this.app.errors.RATE_LIMIT_EXCEEDED.setData({ url: req.url, resetAt }))
75
- }
76
- }
77
- }
78
-
79
- /**
80
- * Parses incoming JSON data to req.body
81
- * @see https://github.com/expressjs/body-parser#bodyparserjsonoptions
82
- * @return {Function} Express middleware function
83
- */
84
- bodyParserJson () {
85
- return (req, res, next) => {
86
- bodyParser.json()(req, res, (error, ...args) => {
87
- if (error) return next(this.app.errors.BODY_PARSE_FAILED.setData({ error: error.message }))
88
- next(null, ...args)
89
- })
90
- }
91
- }
92
-
93
- /**
94
- * Parses incoming URL-encoded data to req.body
95
- * @see https://github.com/expressjs/body-parser#bodyparserurlencodedoptions
96
- * @return {Function} Express middleware function
97
- */
98
- bodyParserUrlEncoded () {
99
- return (req, res, next) => {
100
- bodyParser.urlencoded({ extended: true })(req, res, (error, ...args) => {
101
- if (error) return next(this.app.errors.BODY_PARSE_FAILED.setData({ error: error.message }))
102
- next(null, ...args)
103
- })
104
- }
105
- }
106
-
107
- /**
108
- * Sets default file upload options
109
- * @param {object} options The initial options object
110
- * @returns {FileUploadOptions}
111
- */
112
- setDefaultFileOptions (options = {}) {
113
- Object.entries({
114
- maxFileSize: this.getConfig('fileUploadMaxFileSize'),
115
- multiples: true,
116
- uploadDir: this.getConfig('uploadTempDir'),
117
- promisify: false,
118
- unzip: false,
119
- removeZipSource: true
120
- }).forEach(([k, v]) => {
121
- if (k === 'expectedFileTypes' && !Array.isArray(v)) v = [v]
122
- if (!Object.prototype.hasOwnProperty.call(options, k)) options[k] = v
123
- })
124
- }
125
-
126
- /**
127
- * Handles incoming file uploads
128
- * @param {Array<String>} expectedFileTypes List of file types to accept
129
- * @param {FileUploadOptions} options
130
- * @return {Function} The Express handler
131
- */
132
- fileUploadParser (expectedFileTypes, options = {}) {
133
- options.expectedFileTypes = expectedFileTypes
134
- return (req, res, next) => {
135
- return new Promise(async (resolve, reject) => {
136
- const middleware = await App.instance.waitForModule('middleware')
137
- middleware.setDefaultFileOptions(options)
138
-
139
- if (options.promisify) {
140
- next = e => e ? reject(e) : resolve()
141
- }
142
- if (!req.headers['content-type']?.startsWith('multipart/form-data')) {
143
- return next()
144
- }
145
- try {
146
- await fs.mkdir(options.uploadDir, { recursive: true })
147
- } catch (e) {
148
- if (e.code !== 'EEXIST') return next(e)
149
- }
150
- formidable(options).parse(req, async (error, fields, files) => {
151
- if (error) {
152
- if (error.code === 1009) {
153
- const [maxSize, size] = error.message.match(/(\d+) bytes/g).map(s => bytes(Number(s.replace(' bytes', ''))))
154
- error = App.instance.errors.FILE_EXCEEDS_MAX_SIZE.setData({ maxSize, size })
155
- }
156
- return next(error)
157
- }
158
- // covert fields back from arrays and add to body
159
- Object.keys(fields).forEach(k => {
160
- let val = fields[k][0]
161
- try { val = JSON.parse(val) } catch (e) {}
162
- req.body[k] = val
163
- })
164
- if (Object.keys(files).length === 0) { // no files uploaded
165
- return next()
166
- }
167
- try {
168
- await validateUploadedFiles(req, files, options)
169
- } catch (e) {
170
- return next(e)
171
- }
172
- if (options.unzip) {
173
- await Promise.all(Object.entries(files).map(async ([k, [f]]) => {
174
- if (!middleware.isZip(f.mimetype)) {
175
- return Promise.resolve()
176
- }
177
- f.mimetype = 'application/zip' // always set to the same value for easier checking elsewhere
178
- f.filepath = await unzip(f.filepath, `${f.filepath}_unzip`, { removeSource: options.removeZipSource || true })
179
- }))
180
- }
181
- Object.assign(req, { fileUpload: { files } })
182
- next()
183
- })
184
- })
185
- }
186
- }
187
-
188
- /**
189
- * Handles incoming file uploads via URL
190
- * @param {Array<String>} expectedFileTypes List of file types to accept
191
- * @param {FileUploadOptions} options
192
- * @return {Function} The Express handler
193
- */
194
- urlUploadParser (expectedFileTypes, options) {
195
- options.expectedFileTypes = expectedFileTypes
196
- return (req, res, next) => {
197
- return new Promise(async (resolve, reject) => {
198
- const middleware = await App.instance.waitForModule('middleware')
199
- middleware.setDefaultFileOptions(options)
200
-
201
- if (options.promisify) {
202
- next = e => e ? reject(e) : resolve()
203
- }
204
- if (!req.body.url) {
205
- return next()
206
- }
207
- let responseData
208
- try {
209
- responseData = (await axios.get(req.body.url, { responseType: 'stream' })).data
210
- } catch (e) {
211
- if (e.code === 'ERR_INVALID_URL' || e.response.status === 404) {
212
- return next(this.app.errors.INVALID_ASSET_URL.setData({ url: req.body.url }))
213
- }
214
- return next(e)
215
- }
216
- const contentType = responseData.headers['content-type']
217
- const subtype = contentType.split('/')[1]
218
- const fileName = `${new Date().getTime()}.${subtype}`
219
- const uploadPath = path.resolve(options.uploadDir, fileName)
220
- // set up file data to mimic formidable
221
- const fileData = {
222
- fields: req.apiData.data,
223
- files: {
224
- file: [{
225
- filepath: uploadPath,
226
- originalFilename: fileName,
227
- newFilename: fileName,
228
- mimetype: contentType,
229
- size: Number(responseData.headers['content-length'])
230
- }]
231
- }
232
- }
233
- let fileStream
234
- try {
235
- validateUploadedFiles(req, fileData.files, options)
236
- await fs.mkdir(options.uploadDir, { recursive: true })
237
- fileStream = createWriteStream(uploadPath)
238
- } catch (e) {
239
- if (e.code !== 'EEXIST') return next(e)
240
- }
241
- responseData.pipe(fileStream).on('close', async () => {
242
- req.fileUpload = fileData
243
- if (subtype === 'zip' && options.unzip) {
244
- req.fileUpload.files.course.filepath = await unzip(uploadPath, `${uploadPath}_unzip`, { removeSource: options.removeSource })
245
- }
246
- next()
247
- }).on('error', next)
248
- })
249
- }
250
- }
251
- }
252
- /** @ignore */
253
- async function validateUploadedFiles (req, filesObj, options) {
254
- const errors = App.instance.errors
255
- const assetErrors = []
256
- const filesArr = Object.values(filesObj).reduce((memo, f) => memo.concat(f), []) // flatten nested arrays
257
- await Promise.all(filesArr.map(async f => {
258
- if (!options.expectedFileTypes.includes(f.mimetype)) {
259
- // formidable mimetype isn't allowed, try inspecting the file
260
- f.mimetype = (await fileTypeFromFile(f.filepath))?.mime
261
- if(!f.mimetype && path.extname(f.originalFilename ) === '.srt') {
262
- f.mimetype = 'application/x-subrip'
263
- }
264
- if (!options.expectedFileTypes.includes(f.mimetype)) {
265
- assetErrors.push(errors.UNEXPECTED_FILE_TYPES.setData({ expectedFileTypes: options.expectedFileTypes, invalidFiles: [f.originalFilename], mimetypes: [f.mimetype] }))
266
- }
267
- }
268
- if (!f.size > options.maxFileSize) {
269
- assetErrors.push(errors.FILE_EXCEEDS_MAX_SIZE.setData({ size: bytes(f.size), maxSize: bytes(options.maxFileSize) }))
270
- }
271
- }))
272
- if (assetErrors.length) {
273
- throw errors.VALIDATION_FAILED
274
- .setData({ schemaName: 'fileupload', errors: assetErrors.map(req.translate).join(', ') })
275
- }
276
- }
277
-
278
- export default MiddlewareModule
1
+ import { AbstractModule, App } from 'adapt-authoring-core'
2
+ import axios from 'axios'
3
+ import bodyParser from 'body-parser'
4
+ import bytes from 'bytes'
5
+ import compression from 'compression'
6
+ import { createWriteStream } from 'fs'
7
+ import { fileTypeFromFile } from 'file-type'
8
+ import formidable from 'formidable'
9
+ import fs from 'fs/promises'
10
+ import path from 'path'
11
+ import helmet from 'helmet'
12
+ import { RateLimiterMongo } from 'rate-limiter-flexible'
13
+ import { unzip } from 'zipper'
14
+ /**
15
+ * Adds useful Express middleware to the server stack
16
+ * @memberof middleware
17
+ * @extends {AbstractModule}
18
+ */
19
+ class MiddlewareModule extends AbstractModule {
20
+ get zipTypes () {
21
+ return [
22
+ 'application/zip',
23
+ 'application/x-zip-compressed'
24
+ ]
25
+ }
26
+
27
+ isZip (mimeType) {
28
+ return this.zipTypes.includes(mimeType)
29
+ }
30
+
31
+ /** @override */
32
+ async init () {
33
+ const server = await this.app.waitForModule('server')
34
+ const helmetFunc = helmet({
35
+ xFrameOptions: false
36
+ })
37
+ // add custom middleware
38
+ // server.root.addMiddleware(helmetFunc)
39
+ server.api.addMiddleware(
40
+ helmetFunc,
41
+ this.rateLimiter(),
42
+ this.bodyParserJson(),
43
+ this.bodyParserUrlEncoded(),
44
+ compression()
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Limits how many requests indivual IPs can make
50
+ * @return {Function} Express middleware function
51
+ */
52
+ async rateLimiter () {
53
+ const mongodb = await this.app.waitForModule('mongodb')
54
+ const { db } = await mongodb.getStats()
55
+ const rateLimiter = new RateLimiterMongo({
56
+ storeClient: mongodb.client,
57
+ dbName: db,
58
+ keyPrefix: 'ratelimiter',
59
+ points: this.getConfig('apiRequestLimit'),
60
+ duration: this.getConfig('apiRequestLimitDuration') / 1000
61
+ })
62
+ return async (req, res, next) => {
63
+ let resetAt
64
+ try {
65
+ const data = await rateLimiter.consume(req.ip)
66
+ resetAt = new Date(Date.now() + data.msBeforeNext)
67
+ res.set({
68
+ 'Retry-After': data.msBeforeNext / 1000,
69
+ 'X-RateLimit-Limit': this.getConfig('apiRequestLimit'),
70
+ 'X-RateLimit-Remaining': data.remainingPoints,
71
+ 'X-RateLimit-Reset': resetAt
72
+ })
73
+ next()
74
+ } catch (e) {
75
+ res.sendError(this.app.errors.RATE_LIMIT_EXCEEDED.setData({ url: req.url, resetAt }))
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Parses incoming JSON data to req.body
82
+ * @see https://github.com/expressjs/body-parser#bodyparserjsonoptions
83
+ * @return {Function} Express middleware function
84
+ */
85
+ bodyParserJson () {
86
+ return (req, res, next) => {
87
+ bodyParser.json()(req, res, (error, ...args) => {
88
+ if (error) return next(this.app.errors.BODY_PARSE_FAILED.setData({ error: error.message }))
89
+ next(null, ...args)
90
+ })
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Parses incoming URL-encoded data to req.body
96
+ * @see https://github.com/expressjs/body-parser#bodyparserurlencodedoptions
97
+ * @return {Function} Express middleware function
98
+ */
99
+ bodyParserUrlEncoded () {
100
+ return (req, res, next) => {
101
+ bodyParser.urlencoded({ extended: true })(req, res, (error, ...args) => {
102
+ if (error) return next(this.app.errors.BODY_PARSE_FAILED.setData({ error: error.message }))
103
+ next(null, ...args)
104
+ })
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Sets default file upload options
110
+ * @param {object} options The initial options object
111
+ * @returns {FileUploadOptions}
112
+ */
113
+ setDefaultFileOptions (options = {}) {
114
+ Object.entries({
115
+ maxFileSize: this.getConfig('fileUploadMaxFileSize'),
116
+ multiples: true,
117
+ uploadDir: this.getConfig('uploadTempDir'),
118
+ promisify: false,
119
+ unzip: false,
120
+ removeZipSource: true
121
+ }).forEach(([k, v]) => {
122
+ if (k === 'expectedFileTypes' && !Array.isArray(v)) v = [v]
123
+ if (!Object.prototype.hasOwnProperty.call(options, k)) options[k] = v
124
+ })
125
+ }
126
+
127
+ /**
128
+ * Handles incoming file uploads
129
+ * @param {Array<String>} expectedFileTypes List of file types to accept
130
+ * @param {FileUploadOptions} options
131
+ * @return {Function} The Express handler
132
+ */
133
+ fileUploadParser (expectedFileTypes, options = {}) {
134
+ options.expectedFileTypes = expectedFileTypes
135
+ return (req, res, next) => {
136
+ // Below is wrapped in a promise so other code can use the Promise interface rather than a standard callback
137
+ return new Promise(async (resolve, reject) => { // eslint-disable-line no-async-promise-executor
138
+ const middleware = await App.instance.waitForModule('middleware')
139
+ middleware.setDefaultFileOptions(options)
140
+
141
+ if (options.promisify) {
142
+ next = e => e ? reject(e) : resolve()
143
+ }
144
+ if (!req.headers['content-type']?.startsWith('multipart/form-data')) {
145
+ return next()
146
+ }
147
+ try {
148
+ await fs.mkdir(options.uploadDir, { recursive: true })
149
+ } catch (e) {
150
+ if (e.code !== 'EEXIST') return next(e)
151
+ }
152
+ formidable(options).parse(req, async (error, fields, files) => {
153
+ if (error) {
154
+ if (error.code === 1009) {
155
+ const [maxSize, size] = error.message.match(/(\d+) bytes/g).map(s => bytes(Number(s.replace(' bytes', ''))))
156
+ error = App.instance.errors.FILE_EXCEEDS_MAX_SIZE.setData({ maxSize, size })
157
+ }
158
+ return next(error)
159
+ }
160
+ // covert fields back from arrays and add to body
161
+ Object.keys(fields).forEach(k => {
162
+ let val = fields[k][0]
163
+ try { val = JSON.parse(val) } catch (e) {}
164
+ req.body[k] = val
165
+ })
166
+ if (Object.keys(files).length === 0) { // no files uploaded
167
+ return next()
168
+ }
169
+ try {
170
+ await validateUploadedFiles(req, files, options)
171
+ } catch (e) {
172
+ return next(e)
173
+ }
174
+ if (options.unzip) {
175
+ await Promise.all(Object.entries(files).map(async ([k, [f]]) => {
176
+ if (!middleware.isZip(f.mimetype)) {
177
+ return Promise.resolve()
178
+ }
179
+ f.mimetype = 'application/zip' // always set to the same value for easier checking elsewhere
180
+ f.filepath = await unzip(f.filepath, `${f.filepath}_unzip`, { removeSource: options.removeZipSource || true })
181
+ }))
182
+ }
183
+ Object.assign(req, { fileUpload: { files } })
184
+ next()
185
+ })
186
+ })
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Handles incoming file uploads via URL
192
+ * @param {Array<String>} expectedFileTypes List of file types to accept
193
+ * @param {FileUploadOptions} options
194
+ * @return {Function} The Express handler
195
+ */
196
+ urlUploadParser (expectedFileTypes, options) {
197
+ options.expectedFileTypes = expectedFileTypes
198
+ return (req, res, next) => {
199
+ // Below is wrapped in a promise so other code can use the Promise interface rather than a standard callback
200
+ return new Promise(async (resolve, reject) => { // eslint-disable-line no-async-promise-executor
201
+ const middleware = await App.instance.waitForModule('middleware')
202
+ middleware.setDefaultFileOptions(options)
203
+
204
+ if (options.promisify) {
205
+ next = e => e ? reject(e) : resolve()
206
+ }
207
+ if (!req.body.url) {
208
+ return next()
209
+ }
210
+ let responseData
211
+ try {
212
+ responseData = (await axios.get(req.body.url, { responseType: 'stream' })).data
213
+ } catch (e) {
214
+ if (e.code === 'ERR_INVALID_URL' || e.response.status === 404) {
215
+ return next(this.app.errors.INVALID_ASSET_URL.setData({ url: req.body.url }))
216
+ }
217
+ return next(e)
218
+ }
219
+ const contentType = responseData.headers['content-type']
220
+ const subtype = contentType.split('/')[1]
221
+ const fileName = `${new Date().getTime()}.${subtype}`
222
+ const uploadPath = path.resolve(options.uploadDir, fileName)
223
+ // set up file data to mimic formidable
224
+ const fileData = {
225
+ fields: req.apiData.data,
226
+ files: {
227
+ file: [{
228
+ filepath: uploadPath,
229
+ originalFilename: fileName,
230
+ newFilename: fileName,
231
+ mimetype: contentType,
232
+ size: Number(responseData.headers['content-length'])
233
+ }]
234
+ }
235
+ }
236
+ let fileStream
237
+ try {
238
+ validateUploadedFiles(req, fileData.files, options)
239
+ await fs.mkdir(options.uploadDir, { recursive: true })
240
+ fileStream = createWriteStream(uploadPath)
241
+ } catch (e) {
242
+ if (e.code !== 'EEXIST') return next(e)
243
+ }
244
+ responseData.pipe(fileStream).on('close', async () => {
245
+ req.fileUpload = fileData
246
+ if (subtype === 'zip' && options.unzip) {
247
+ req.fileUpload.files.course.filepath = await unzip(uploadPath, `${uploadPath}_unzip`, { removeSource: options.removeSource })
248
+ }
249
+ next()
250
+ }).on('error', next)
251
+ })
252
+ }
253
+ }
254
+ }
255
+ /** @ignore */
256
+ async function validateUploadedFiles (req, filesObj, options) {
257
+ const errors = App.instance.errors
258
+ const assetErrors = []
259
+ const filesArr = Object.values(filesObj).reduce((memo, f) => memo.concat(f), []) // flatten nested arrays
260
+ await Promise.all(filesArr.map(async f => {
261
+ if (!options.expectedFileTypes.includes(f.mimetype)) {
262
+ // formidable mimetype isn't allowed, try inspecting the file
263
+ f.mimetype = (await fileTypeFromFile(f.filepath))?.mime
264
+ if (!f.mimetype && path.extname(f.originalFilename) === '.srt') {
265
+ f.mimetype = 'application/x-subrip'
266
+ }
267
+ if (!options.expectedFileTypes.includes(f.mimetype)) {
268
+ assetErrors.push(errors.UNEXPECTED_FILE_TYPES.setData({ expectedFileTypes: options.expectedFileTypes, invalidFiles: [f.originalFilename], mimetypes: [f.mimetype] }))
269
+ }
270
+ }
271
+ if (!f.size > options.maxFileSize) {
272
+ assetErrors.push(errors.FILE_EXCEEDS_MAX_SIZE.setData({ size: bytes(f.size), maxSize: bytes(options.maxFileSize) }))
273
+ }
274
+ }))
275
+ if (assetErrors.length) {
276
+ throw errors.VALIDATION_FAILED
277
+ .setData({ schemaName: 'fileupload', errors: assetErrors.map(req.translate).join(', ') })
278
+ }
279
+ }
280
+
281
+ export default MiddlewareModule
package/lib/typedefs.js CHANGED
@@ -1,13 +1,13 @@
1
- /**
2
- * This file exists to define the below types for documentation purposes.
3
- */
4
- /**
5
- * Options which can be passed to file upload middleware
6
- * @memberof middleware
7
- * @typedef {Object} FileUploadOptions
8
- * @property {number} maxFileSize Maximum file size allowed by upload
9
- * @property {string} uploadDir Directory file upload should be stored
10
- * @property {Boolean} promisify If true, middleware will return a promise rather than use the standard callback. Useful when calling middleware outside of an Express middleware stack
11
- * @property {Boolean} removeZipSource To be used in conjunction with the unzip option. Whether the original zip file should be removed after unzipping (true by default)
12
- * @property {Boolean} unzip Whether any zip files should be unzipped by the handler
13
- */
1
+ /**
2
+ * This file exists to define the below types for documentation purposes.
3
+ */
4
+ /**
5
+ * Options which can be passed to file upload middleware
6
+ * @memberof middleware
7
+ * @typedef {Object} FileUploadOptions
8
+ * @property {number} maxFileSize Maximum file size allowed by upload
9
+ * @property {string} uploadDir Directory file upload should be stored
10
+ * @property {Boolean} promisify If true, middleware will return a promise rather than use the standard callback. Useful when calling middleware outside of an Express middleware stack
11
+ * @property {Boolean} removeZipSource To be used in conjunction with the unzip option. Whether the original zip file should be removed after unzipping (true by default)
12
+ * @property {Boolean} unzip Whether any zip files should be unzipped by the handler
13
+ */
package/package.json CHANGED
@@ -1,29 +1,63 @@
1
- {
2
- "name": "adapt-authoring-middleware",
3
- "version": "0.0.1",
4
- "description": "Express middleware to be added to the server",
5
- "homepage": "https://github.com/adapt-security/adapt-authoring-middleware",
6
- "license": "GPL-3.0",
7
- "type": "module",
8
- "main": "index.js",
9
- "repository": "github:adapt-security/adapt-authoring-middleware",
10
- "dependencies": {
11
- "axios": "^1.6.7",
12
- "body-parser": "^1.20.2",
13
- "bytes": "^3.1.2",
14
- "compression": "^1.7.4",
15
- "file-type": "^20.5.0",
16
- "formidable": "^3.5.1",
17
- "helmet": "^8.0.0",
18
- "lodash": "^4.17.21",
19
- "rate-limiter-flexible": "^5.0.3",
20
- "zipper": "github:adapt-security/zipper"
21
- },
22
- "peerDependencies": {
23
- "adapt-authoring-core": "github:adapt-security/adapt-authoring-core"
24
- },
25
- "devDependencies": {
26
- "eslint": "^9.14.0",
27
- "standard": "^17.1.0"
28
- }
29
- }
1
+ {
2
+ "name": "adapt-authoring-middleware",
3
+ "version": "1.0.0",
4
+ "description": "Express middleware to be added to the server",
5
+ "homepage": "https://github.com/adapt-security/adapt-authoring-middleware",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "repository": "github:adapt-security/adapt-authoring-middleware",
10
+ "dependencies": {
11
+ "axios": "^1.6.7",
12
+ "body-parser": "^1.20.2",
13
+ "bytes": "^3.1.2",
14
+ "compression": "^1.7.4",
15
+ "file-type": "^20.5.0",
16
+ "formidable": "^3.5.1",
17
+ "helmet": "^8.0.0",
18
+ "lodash": "^4.17.21",
19
+ "rate-limiter-flexible": "^7.3.0",
20
+ "zipper": "github:adapt-security/zipper"
21
+ },
22
+ "peerDependencies": {
23
+ "adapt-authoring-core": "github:adapt-security/adapt-authoring-core"
24
+ },
25
+ "devDependencies": {
26
+ "standard": "^17.1.0",
27
+ "@semantic-release/commit-analyzer": "^9.0.2",
28
+ "@semantic-release/git": "^10.0.1",
29
+ "@semantic-release/github": "^8.0.5",
30
+ "@semantic-release/npm": "^9.0.1",
31
+ "@semantic-release/release-notes-generator": "^10.0.3",
32
+ "conventional-changelog-eslint": "^3.0.9",
33
+ "semantic-release": "^21.0.1",
34
+ "semantic-release-replace-plugin": "^1.2.7"
35
+ },
36
+ "release": {
37
+ "plugins": [
38
+ [
39
+ "@semantic-release/commit-analyzer",
40
+ {
41
+ "preset": "eslint"
42
+ }
43
+ ],
44
+ [
45
+ "@semantic-release/release-notes-generator",
46
+ {
47
+ "preset": "eslint"
48
+ }
49
+ ],
50
+ "@semantic-release/npm",
51
+ "@semantic-release/github",
52
+ [
53
+ "@semantic-release/git",
54
+ {
55
+ "assets": [
56
+ "package.json"
57
+ ],
58
+ "message": "Chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
59
+ }
60
+ ]
61
+ ]
62
+ }
63
+ }
package/.eslintignore DELETED
@@ -1 +0,0 @@
1
- node_modules
package/.eslintrc DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "env": {
3
- "browser": false,
4
- "node": true,
5
- "commonjs": false,
6
- "es2020": true
7
- },
8
- "extends": [
9
- "standard"
10
- ],
11
- "parserOptions": {
12
- "ecmaVersion": 2020
13
- }
14
- }