UNPKG

@payloadcms/plugin-form-builder

Version:

Form builder plugin for Payload CMS

274 lines (273 loc) 12.3 kB
import { ValidationError } from 'payload'; import { validateMimeType } from 'payload/shared'; /** * Handles upload fields in form submissions. * * This hook: * 1. Checks req.files for files matching upload field names (supports multiple files per field) * 2. Validates MIME types and file sizes before uploading * 3. Creates media documents in the appropriate upload collection * 4. Populates submissionUploads with proper Payload upload relationships * 5. Strips upload fields from submissionData (they live in submissionUploads only) * 6. Validates pre-uploaded file IDs for backwards compatibility */ export const handleUploads = async (beforeChangeParams, formConfig)=>{ const { data, operation, req } = beforeChangeParams; const { payload } = req; // Only handle on create if (operation !== 'create') { return data; } const { form: formID, submissionData } = data || {}; if (!formID || !submissionData) { return data; } const formSlug = formConfig?.formOverrides?.slug || 'forms'; // Fetch the form to get field configurations let form; try { form = await payload.findByID({ id: formID, collection: formSlug, req }); } catch { // If form doesn't exist, let the form relationship validation handle it return data; } const formFields = form?.fields || []; const uploadFields = formFields.filter((field)=>field.blockType === 'upload'); if (uploadFields.length === 0) { return data; } const submissionDataArray = submissionData; // Build a map of pre-uploaded file IDs for backwards-compat lookup const submissionMap = new Map(); for(let i = 0; i < submissionDataArray.length; i++){ const item = submissionDataArray[i]; if (item) { submissionMap.set(item.field, { index: i, value: item.value }); } } // Get files from request (populated by addDataAndFileToRequest). // Multiple files with the same field name are stored as an array by buildFields. const requestFiles = req.files || {}; const errors = []; // Accumulate upload items per field — one entry per upload field in the form const uploadsByField = new Map(); // Track docs created during this hook so we can delete them if a later error causes rollback const createdDocs = []; for (const uploadField of uploadFields){ const { name, maxFileSize, mimeTypes, multiple, required, uploadCollection } = uploadField; const fieldLabel = uploadField.label || name; uploadsByField.set(name, []); // Validate uploadCollection is configured in this plugin (defence-in-depth against tampered form docs) if (!formConfig.uploadCollections?.includes(uploadCollection)) { errors.push({ field: name, message: `Upload collection "${uploadCollection}" is not configured in this plugin` }); continue; } // Normalize: multiple files with the same field name arrive as an array const rawFile = requestFiles[name]; const requestFileList = rawFile ? Array.isArray(rawFile) ? rawFile : [ rawFile ] : []; const existingSubmission = submissionMap.get(name); if (requestFileList.length > 0) { // Guard: reject multiple files when the field doesn't allow them if (!multiple && requestFileList.length > 1) { errors.push({ field: name, message: `${fieldLabel} does not allow multiple files` }); continue; } // Direct file upload(s) from request for (const requestFile of requestFileList){ // Validate MIME type before uploading if (mimeTypes && mimeTypes.length > 0) { const allowedPatterns = mimeTypes.map((m)=>m.mimeType); if (!validateMimeType(requestFile.mimetype, allowedPatterns)) { errors.push({ field: name, message: `${fieldLabel}: File type "${requestFile.mimetype}" is not allowed. Allowed types: ${allowedPatterns.join(', ')}` }); continue; } } // Validate file size before uploading if (maxFileSize && maxFileSize > 0 && requestFile.size > maxFileSize) { const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(2); const fileSizeMB = (requestFile.size / (1024 * 1024)).toFixed(2); errors.push({ field: name, message: `${fieldLabel}: File size (${fileSizeMB}MB) exceeds maximum allowed size (${maxSizeMB}MB)` }); continue; } // Create the media document. // Note: payload.create writes the file to storage before the DB row exists. If // the DB write throws, createdDocs never records the entry and the file on disk // will not be cleaned up by the rollback loop below. Payload core owns that // rollback path — we only clean up docs we successfully created here. try { const mediaDoc = await payload.create({ collection: uploadCollection, data: {}, file: { name: requestFile.name, data: requestFile.data, mimetype: requestFile.mimetype, size: requestFile.size }, req }); createdDocs.push({ id: mediaDoc.id, collection: uploadCollection }); uploadsByField.get(name).push({ relationTo: uploadCollection, value: mediaDoc.id }); } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error'; errors.push({ field: name, message: `${fieldLabel}: Failed to upload file - ${errorMessage}` }); } } } else if (existingSubmission && existingSubmission.value) { // Backwards compatibility: validate pre-uploaded file IDs const submittedValue = existingSubmission.value; const submittedValueStr = typeof submittedValue === 'string' || typeof submittedValue === 'number' ? String(submittedValue) : ''; if (!submittedValueStr) { if (required) { errors.push({ field: name, message: `${fieldLabel} is required` }); } continue; } // Guard: reject comma-separated IDs when the field doesn't allow multiple files. // Assumes IDs never contain literal commas — true for ObjectIDs/UUIDs and any numeric ID. if (!multiple && submittedValueStr.includes(',')) { errors.push({ field: name, message: `${fieldLabel} does not allow multiple files` }); continue; } // Parse file IDs (comma-separated for multiple files) const fileIds = multiple ? submittedValueStr.split(',').map((id)=>id.trim()).filter(Boolean) : [ submittedValueStr ]; // Validate each file for (const fileId of fileIds){ let fileDoc = null; let fileIsValid = true; try { fileDoc = await payload.findByID({ id: fileId, collection: uploadCollection, req }); } catch { errors.push({ field: name, message: `${fieldLabel}: File with ID "${fileId}" not found in collection "${uploadCollection}"` }); fileIsValid = false; continue; } if (!fileDoc) { errors.push({ field: name, message: `${fieldLabel}: File with ID "${fileId}" not found` }); fileIsValid = false; continue; } // Validate mimeType if (mimeTypes && mimeTypes.length > 0) { const allowedPatterns = mimeTypes.map((m)=>m.mimeType); const fileMimeType = fileDoc.mimeType; if (fileMimeType && !validateMimeType(fileMimeType, allowedPatterns)) { errors.push({ field: name, message: `${fieldLabel}: File type "${fileMimeType}" is not allowed. Allowed types: ${allowedPatterns.join(', ')}` }); fileIsValid = false; continue; } } // Validate file size if (maxFileSize && maxFileSize > 0) { const fileSize = fileDoc.filesize; if (fileSize && fileSize > maxFileSize) { const maxSizeMB = (maxFileSize / (1024 * 1024)).toFixed(2); const fileSizeMB = (fileSize / (1024 * 1024)).toFixed(2); errors.push({ field: name, message: `${fieldLabel}: File size (${fileSizeMB}MB) exceeds maximum allowed size (${maxSizeMB}MB)` }); fileIsValid = false; } } if (fileIsValid) { uploadsByField.get(name).push({ relationTo: uploadCollection, value: fileDoc.id }); } } } else if (required) { // No file in request and no pre-uploaded file ID, but field is required errors.push({ field: name, message: `${fieldLabel} is required` }); } } if (errors.length > 0) { // Best-effort cleanup of any docs created before the error was detected for (const doc of createdDocs){ try { await payload.delete({ id: doc.id, collection: doc.collection, req }); } catch { // best-effort — don't mask the original validation error } } throw new ValidationError({ collection: formConfig?.formSubmissionOverrides?.slug || 'form-submissions', errors: errors.map((error)=>({ message: error.message, path: error.field })) }); } // Strip upload fields from submissionData — they are stored in submissionUploads only const uploadFieldNames = new Set(uploadFields.map((f)=>f.name)); const cleanedSubmissionData = submissionDataArray.filter((item)=>!uploadFieldNames.has(item.field)); // One entry per upload field; omit fields with no successful uploads const updatedSubmissionUploads = Array.from(uploadsByField.entries()).filter(([, items])=>items.length > 0).map(([field, value])=>({ field, value })); return { ...data, submissionData: cleanedSubmissionData, submissionUploads: updatedSubmissionUploads }; }; //# sourceMappingURL=handleUploads.js.map