-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathFileUploadPageController.ts
More file actions
496 lines (411 loc) · 14.6 KB
/
FileUploadPageController.ts
File metadata and controls
496 lines (411 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
import { ComponentType, type PageFileUpload } from '@defra/forms-model'
import Boom from '@hapi/boom'
import { wait } from '@hapi/hoek'
import { StatusCodes } from 'http-status-codes'
import { type ValidationErrorItem } from 'joi'
import {
FileUploadField,
tempItemSchema
} from '~/src/server/plugins/engine/components/FileUploadField.js'
import { type FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js'
import {
getCacheService,
getError,
getExponentialBackoffDelay
} from '~/src/server/plugins/engine/helpers.js'
import { type FormModel } from '~/src/server/plugins/engine/models/index.js'
import { QuestionPageController } from '~/src/server/plugins/engine/pageControllers/QuestionPageController.js'
import { getProxyUrlForLocalDevelopment } from '~/src/server/plugins/engine/pageControllers/helpers/index.js'
import {
getUploadStatus,
initiateUpload
} from '~/src/server/plugins/engine/services/uploadService.js'
import {
FileStatus,
UploadStatus,
type AnyFormRequest,
type FeaturedFormPageViewModel,
type FileState,
type FormContext,
type FormContextRequest,
type FormSubmissionError,
type FormSubmissionState,
type ItemDeletePageViewModel,
type UploadInitiateResponse,
type UploadStatusFileResponse
} from '~/src/server/plugins/engine/types.js'
import {
type FormRequest,
type FormRequestPayload,
type FormResponseToolkit
} from '~/src/server/routes/types.js'
const MAX_UPLOADS = 25
const CDP_UPLOAD_TIMEOUT_MS = 60000 // 1 minute
export function prepareStatus(status: UploadStatusFileResponse) {
const file = status.form.file
const isPending = file.fileStatus === FileStatus.pending
if (!file.errorMessage && isPending) {
file.errorMessage = 'The selected file has not fully uploaded'
}
return status
}
function prepareFileState(fileState: FileState) {
prepareStatus(fileState.status)
return fileState
}
export class FileUploadPageController extends QuestionPageController {
declare pageDef: PageFileUpload
fileUpload: FileUploadField
fileDeleteViewName = 'item-delete'
constructor(model: FormModel, pageDef: PageFileUpload) {
super(model, pageDef)
const { collection } = this
// Get the file upload fields from the collection
const fileUploads = collection.fields.filter(
(field): field is FileUploadField =>
field.type === ComponentType.FileUploadField
)
const fileUpload = fileUploads.at(0)
// Assert we have exactly 1 file upload component
if (!fileUpload || fileUploads.length > 1) {
throw Boom.badImplementation(
`Expected 1 FileUploadFieldComponent in FileUploadPageController '${pageDef.path}'`
)
}
// Assert the file upload component is the first form component
if (collection.fields.indexOf(fileUpload) !== 0) {
throw Boom.badImplementation(
`Expected '${fileUpload.name}' to be the first form component in FileUploadPageController '${pageDef.path}'`
)
}
// Assign the file upload component to the controller
this.fileUpload = fileUpload
this.viewName = 'file-upload'
}
/**
* Get supplementary state keys for clearing file upload state.
* Returns the nested upload path for FileUploadField components only.
* @param component - The component to get supplementary state keys for
* @returns Array containing the nested upload path, e.g., ["upload['/page-path']"]
* or ['upload'] if no page path is available. Returns empty array for non-FileUploadField components.
*/
getStateKeys(component: FormComponent): string[] {
// Only return upload keys for FileUploadField components
if (!(component instanceof FileUploadField)) {
return []
}
const pagePath = component.page?.path
return pagePath ? [`upload['${pagePath}']`] : ['upload']
}
getFormDataFromState(
request: FormContextRequest | undefined,
state: FormSubmissionState
) {
const { fileUpload } = this
const payload = super.getFormDataFromState(request, state)
const files = this.getFilesFromState(state)
// Append the files to the payload
payload[fileUpload.name] = files.length ? files : undefined
return payload
}
async getState(request: AnyFormRequest) {
const { fileUpload } = this
// Get the actual state
const state = await super.getState(request)
const files = this.getFilesFromState(state)
// Overwrite the files with those in the upload state
state[fileUpload.name] = files
return this.refreshUpload(request, state)
}
/**
* Get the uploaded files from state.
*/
getFilesFromState(state: FormSubmissionState) {
const { path } = this
const uploadState = state.upload?.[path]
return uploadState?.files ?? []
}
/**
* Get the initiated upload from state.
*/
getUploadFromState(state: FormSubmissionState) {
const { path } = this
const uploadState = state.upload?.[path]
return uploadState?.upload
}
makeGetItemDeleteRouteHandler() {
return (
request: FormRequest,
context: FormContext,
h: FormResponseToolkit
) => {
const { viewModel } = this
const { params } = request
const { state } = context
const files = this.getFilesFromState(state)
const fileToRemove = files.find(
({ uploadId }) => uploadId === params.itemId
)
if (!fileToRemove) {
throw Boom.notFound('File to delete not found')
}
const { filename } = fileToRemove.status.form.file
return h.view(this.fileDeleteViewName, {
...viewModel,
context,
backLink: this.getBackLink(request, context),
pageTitle: `Are you sure you want to remove this file?`,
itemTitle: filename,
confirmation: { text: 'You cannot recover removed files.' },
buttonConfirm: { text: 'Remove file' },
buttonCancel: { text: 'Cancel' }
} satisfies ItemDeletePageViewModel)
}
}
makePostItemDeleteRouteHandler() {
return async (
request: FormRequestPayload,
context: FormContext,
h: FormResponseToolkit
) => {
const { path } = this
const { state } = context
const { confirm } = this.getFormParams(request)
// Check for any removed files in the POST payload
if (confirm) {
await this.checkRemovedFiles(request, state)
return this.proceed(request, h, path)
}
return this.proceed(request, h)
}
}
getErrors(details?: ValidationErrorItem[]) {
const { fileUpload } = this
if (details) {
const errors: FormSubmissionError[] = []
details.forEach((error) => {
const isUploadError = error.path[0] === fileUpload.name
const isUploadRootError = isUploadError && error.path.length === 1
if (!isUploadError || isUploadRootError) {
// The error is for the root of the upload or another
// field on the page so defer to the getError helper
errors.push(getError(error))
} else {
const { context, path, type } = error
if (type === 'object.unknown' && path.at(-1) === 'errorMessage') {
const value = context?.value as string | undefined
if (value) {
const name = fileUpload.name
const text = typeof value === 'string' ? value : 'Unknown error'
const href = `#${name}`
errors.push({ path, href, name, text })
}
}
}
})
return errors
}
}
getViewModel(
request: FormContextRequest,
context: FormContext
): FeaturedFormPageViewModel {
const { fileUpload } = this
const { state } = context
const upload = this.getUploadFromState(state)
const viewModel = super.getViewModel(request, context)
const { components } = viewModel
// Featured form component
const [formComponent] = components.filter(
({ model }) => model.id === fileUpload.name
)
const index = components.indexOf(formComponent)
const proxyUrl = getProxyUrlForLocalDevelopment(upload?.uploadUrl)
return {
...viewModel,
formAction: upload?.uploadUrl,
uploadId: upload?.uploadId,
formComponent,
// Split out components before/after
componentsBefore: components.slice(0, index),
components: components.slice(index),
proxyUrl
}
}
/**
* Refreshes the CDP upload and files in the
* state and checks for any removed files.
*
* If an upload exists and hasn't been consumed
* it gets re-used, otherwise we initiate a new one.
* @param request - the hapi request
* @param state - the form state
*/
private async refreshUpload(
request: AnyFormRequest,
state: FormSubmissionState
) {
state = await this.checkUploadStatus(request, state)
return state
}
/**
* If an upload exists and hasn't been consumed
* it gets re-used, otherwise a new one is initiated.
* @param request - the hapi request
* @param state - the form state
* @param depth - the number of retries so far
*/
private async checkUploadStatus(
request: AnyFormRequest,
state: FormSubmissionState,
depth = 1
): Promise<FormSubmissionState> {
const upload = this.getUploadFromState(state)
const files = this.getFilesFromState(state)
// If no upload exists, initiate a new one.
if (!upload?.uploadId) {
return this.initiateAndStoreNewUpload(request, state)
}
const uploadId = upload.uploadId
let statusResponse
try {
statusResponse = await getUploadStatus(uploadId)
} catch (err) {
// if the user loads a file upload page and queries the cached upload, after the upload has
// expired in CDP, we will get a 404 from the getUploadStatus endpoint.
// In this case we want to initiate a new upload and return that state, so the form
// doesn't blow up for the end user.
if (
Boom.isBoom(err) &&
err.output.statusCode === StatusCodes.NOT_FOUND.valueOf()
) {
return this.initiateAndStoreNewUpload(request, state)
}
throw err
}
if (!statusResponse) {
throw Boom.badRequest(
`Unexpected empty response from getUploadStatus for ${uploadId}`
)
}
// Re-use the upload if it is still in the "initiated" state.
if (statusResponse.uploadStatus === UploadStatus.initiated) {
return state
}
if (statusResponse.uploadStatus === UploadStatus.pending) {
// Using exponential backoff delays:
// Depth 1: 2000ms, Depth 2: 4000ms, Depth 3: 8000ms, Depth 4: 16000ms, Depth 5+: 30000ms (capped)
// A depth of 5 (or more) implies cumulative delays roughly reaching 55 seconds.
if (depth >= 5) {
const err = new Error(
`Exceeded cumulative retry delay for ${uploadId} (depth: ${depth}). Re-initiating a new upload.`
)
request.logger.error(
err,
`[uploadTimeout] Exceeded cumulative retry delay for uploadId: ${uploadId} at depth: ${depth} - re-initiating new upload`
)
await this.initiateAndStoreNewUpload(request, state)
throw Boom.gatewayTimeout(
`Timed out waiting for ${uploadId} after cumulative retries exceeding ${((CDP_UPLOAD_TIMEOUT_MS - 5000) / 1000).toFixed(0)} seconds`
)
}
const delay = getExponentialBackoffDelay(depth)
request.logger.info(
`[uploadRetry] Waiting ${delay / 1000} seconds for uploadId: ${uploadId} to complete (retry depth: ${depth})`
)
await wait(delay)
return this.checkUploadStatus(request, state, depth + 1)
}
// Only add to files state if the file validates.
// This secures against html tampering of the file input
// by adding a 'multiple' attribute or it being
// changed to a simple text field or similar.
const validationResult = tempItemSchema.validate(
{ uploadId, status: statusResponse },
{ stripUnknown: true }
)
const error = validationResult.error
const fileState = validationResult.value as FileState
if (error) {
return this.initiateAndStoreNewUpload(request, state)
}
const file = fileState.status.form.file
if (file.fileStatus === FileStatus.complete) {
files.unshift(prepareFileState(fileState))
await this.mergeState(request, state, {
upload: { [this.path]: { files, upload } }
})
} else {
// Flash the error message.
const { fileUpload } = this
const cacheService = getCacheService(request.server)
const name = fileUpload.name
const text = file.errorMessage ?? 'Unknown error'
const errors: FormSubmissionError[] = [
{ path: [name], href: `#${name}`, name, text }
]
cacheService.setFlash(request, { errors })
}
return this.initiateAndStoreNewUpload(request, state)
}
/**
* Checks the payload for a file getting removed
* and removes it from the upload files if found
* @param request - the hapi request
* @param state - the form state
* @returns updated state if any files have been removed
*/
private async checkRemovedFiles(
request: FormRequestPayload,
state: FormSubmissionState
) {
const { path } = this
const { params } = request
const upload = this.getUploadFromState(state)
const files = this.getFilesFromState(state)
const filesUpdated = files.filter(
({ uploadId }) => uploadId !== params.itemId
)
if (filesUpdated.length === files.length) {
return
}
await this.mergeState(request, state, {
upload: { [path]: { files: filesUpdated, upload } }
})
}
/**
* Initiates a CDP file upload and stores in the upload state
* @param request - the hapi request
* @param state - the form state
*/
private async initiateAndStoreNewUpload(
request: AnyFormRequest,
state: FormSubmissionState
) {
const { fileUpload, href, path } = this
const { options, schema } = fileUpload
const { getFormMetadata } = this.model.services.formsService
const files = this.getFilesFromState(state)
// Reset the upload in state
let upload: UploadInitiateResponse | undefined
// Don't initiate anymore after minimum of `schema.max` or MAX_UPLOADS
const max = Math.min(schema.max ?? MAX_UPLOADS, MAX_UPLOADS)
if (files.length < max) {
const formMetadata = await getFormMetadata(request.params.slug)
const notificationEmail =
formMetadata.notificationEmail ?? 'defraforms@defra.gov.uk'
const newUpload = await initiateUpload(
href,
notificationEmail,
options.accept
)
if (newUpload === undefined) {
throw Boom.badRequest('Unexpected empty response from initiateUpload')
}
upload = newUpload
}
return this.mergeState(request, state, {
upload: { [path]: { files, upload } }
})
}
}