/* * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ import { DeaCaseFile } from '@aws/dea-app/lib/models/case-file'; import { Alert, Box, Button, Container, Form, FormField, Header, Icon, Input, Modal, SpaceBetween, Spinner, Table, Textarea, } from '@cloudscape-design/components'; import cryptoJS from 'crypto-js'; import { useRouter } from 'next/router'; import { useState } from 'react'; import { completeUpload, initiateUpload, useGetCaseActions } from '../../api/cases'; import { commonLabels, commonTableLabels, fileOperationsLabels } from '../../common/labels'; import { FileWithPath, formatFileSize } from '../../helpers/fileHelper'; import FileUpload from '../common-components/FileUpload'; import { UploadFilesProps } from './UploadFilesBody'; interface FileUploadProgressRow { fileName: string; status: UploadStatus; fileSizeBytes: number; relativePath: string; } enum UploadStatus { progress = 'Uploading', complete = 'Uploaded', failed = 'Upload failed', } interface ActiveFileUpload { file: FileWithPath; initiatedCaseFilePromise: Promise; } export const ONE_MB = 1024 * 1024; const MAX_PARALLEL_PART_UPLOADS = 10; const MAX_PARALLEL_UPLOADS = 1; // One file concurrently for now. The backend requires a code refactor to deal with the TransactionConflictException thrown ocassionally. function UploadFilesForm(props: UploadFilesProps): JSX.Element { const [selectedFiles, setSelectedFiles] = useState([]); const [uploadedFiles, setUploadedFiles] = useState([]); const [details, setDetails] = useState(''); const [reason, setReason] = useState(''); const [uploadInProgress, setUploadInProgress] = useState(false); const [confirmationVisible, setConfirmationVisible] = useState(false); const userActions = useGetCaseActions(props.caseId); const router = useRouter(); async function onSubmitHandler() { // top level try/finally to set uploadInProgress bool state try { setUploadInProgress(true); setUploadedFiles([ ...uploadedFiles, ...selectedFiles.map((file) => ({ fileName: file.name, fileSizeBytes: Math.max(file.size, 1), status: UploadStatus.progress, relativePath: file.relativePath, })), ]); let position = 0; while (position < selectedFiles.length) { const itemsForBatch = selectedFiles.slice(position, position + MAX_PARALLEL_UPLOADS); await Promise.all(itemsForBatch.map((item) => uploadFile(item))); position += MAX_PARALLEL_UPLOADS; } setSelectedFiles([]); } finally { setUploadInProgress(false); } } async function uploadFilePartsAndComplete(activeFileUpload: ActiveFileUpload) { const initiatedCaseFile = await activeFileUpload.initiatedCaseFilePromise; let uploadPromises: Promise[] = []; const hash = cryptoJS.algo.SHA256.create(); if (initiatedCaseFile && initiatedCaseFile.presignedUrls) { const numberOfUrls = initiatedCaseFile.presignedUrls.length; const chunkSizeBytes = initiatedCaseFile.chunkSizeBytes ? initiatedCaseFile.chunkSizeBytes : 50 * ONE_MB; for (let index = 0; index < initiatedCaseFile.presignedUrls.length; index += 1) { const url = initiatedCaseFile.presignedUrls[index]; const start = index * chunkSizeBytes; const end = (index + 1) * chunkSizeBytes; const filePartPointer = index === numberOfUrls - 1 ? activeFileUpload.file.slice(start) : activeFileUpload.file.slice(start, end); const loadedFilePart = await readFileSlice(filePartPointer); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore. WordArray expects number[] which should be satisfied by Uint8Array hash.update(cryptoJS.lib.WordArray.create(loadedFilePart)); uploadPromises.push(fetch(url, { method: 'PUT', body: loadedFilePart })); if (index > 0 && index % MAX_PARALLEL_PART_UPLOADS === 0) { // getting user actions from api to reset idle timer while upload is in progress userActions.mutate(); await Promise.all(uploadPromises); uploadPromises = []; } } await Promise.all(uploadPromises); await completeUpload({ caseUlid: props.caseId, ulid: initiatedCaseFile.ulid, uploadId: initiatedCaseFile.uploadId, sha256Hash: hash.finalize().toString(), }); } updateFileProgress(activeFileUpload.file, UploadStatus.complete); } async function uploadFile(selectedFile: FileWithPath) { const fileSizeBytes = Math.max(selectedFile.size, 1); // Trying to use small chunk size (50MB) to reduce memory use. // However, since S3 allows a max of 10,000 parts for multipart uploads, we will increase chunk size for larger files const chunkSizeBytes = Math.max(selectedFile.size / 10_000, 50 * ONE_MB); // per file try/finally state to initiate uploads try { const contentType = selectedFile.type ? selectedFile.type : 'text/plain'; const initiatedCaseFilePromise = initiateUpload({ caseUlid: props.caseId, fileName: selectedFile.name, filePath: selectedFile.relativePath, fileSizeBytes, chunkSizeBytes, contentType, reason, details, }); const activeFileUpload = { file: selectedFile, initiatedCaseFilePromise, }; await uploadFilePartsAndComplete(activeFileUpload); } catch (e) { updateFileProgress(selectedFile, UploadStatus.failed); console.log('Upload failed', e); } } function updateFileProgress(selectedFile: FileWithPath, status: UploadStatus) { setUploadedFiles((prev) => { const newList = [...prev]; const fileToUpdateStatus = newList.find( (file) => file.fileName === selectedFile.name && file.relativePath === selectedFile.relativePath && file.status === UploadStatus.progress ); if (fileToUpdateStatus) { fileToUpdateStatus.status = status; } return newList; }); } async function readFileSlice(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { // reader.result is of type // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions resolve(new Uint8Array(reader.result as any)); }; reader.onerror = reject; reader.readAsArrayBuffer(blob); }); } function statusCell(uploadProgress: FileUploadProgressRow) { switch (uploadProgress.status) { case UploadStatus.progress: { return ( {uploadProgress.status} ); } case UploadStatus.failed: { return ( {uploadProgress.status} ); } case UploadStatus.complete: { return ( {uploadProgress.status} ); } default: { return ( {uploadProgress.status} ); } } } function onDoneHandler() { // eslint-disable-next-line @typescript-eslint/no-floating-promises router.push(`/case-detail?caseId=${props.caseId}`); } function validateFields(): boolean { return reason.length > 1 && details.length > 1; } return ( setConfirmationVisible(false)} visible={confirmationVisible} closeAriaLabel="Close modal" footer={ } header={fileOperationsLabels.modalTitle} > {fileOperationsLabels.modalBody}
{fileOperationsLabels.uploadDetailsLabel} } > 1 ? '' : commonLabels.requiredLength} >