UNPKG

@gov-cy/govcy-express-services

Version:

An Express-based system that dynamically renders services using @gov-cy/govcy-frontend-renderer and posts data to a submission API.

363 lines (329 loc) 12.4 kB
import FormData from 'form-data'; import { getPageConfigData } from "./govcyLoadConfigData.mjs"; import { evaluatePageConditions } from "./govcyExpressions.mjs"; import { getEnvVariable, getEnvVariableBool } from "./govcyEnvVariables.mjs"; import { ALLOWED_FILE_MIME_TYPES, ALLOWED_FILE_SIZE_MB } from "./govcyConstants.mjs"; import { govcyApiRequest } from "./govcyApiRequest.mjs"; import * as dataLayer from "./govcyDataLayer.mjs"; import { logger } from './govcyLogger.mjs'; /** * Handles the logic for uploading a file to the configured Upload API. * Does not send a response — just returns a standard object to be handled by middleware. * * @param {object} opts - Input parameters * @param {object} opts.service - The service config object * @param {object} opts.store - Session store (req.session) * @param {string} opts.siteId - Site ID * @param {string} opts.pageUrl - Page URL * @param {string} opts.elementName - Name of file input * @param {object} opts.file - File object from multer (req.file) * @param {string} opts.mode - Upload mode ("single" | "multipleThingsDraft" | "multipleThingsEdit") * @param {number|null} opts.index - Numeric index for edit mode (0-based), or null * @returns {Promise<{ status: number, data?: object, errorMessage?: string }>} */ export async function handleFileUpload({ service, store, siteId, pageUrl, elementName, file, mode = "single", // "single" | "multipleThingsDraft" | "multipleThingsEdit" index = null // numeric index for edit mode }) { try { // Validate essentials // Early exit if key things are missing if (!file || !elementName) { return { status: 400, dataStatus: 400, errorMessage: 'Missing file or element name' }; } // Get the upload configuration const uploadCfg = service?.site?.fileUploadAPIEndpoint; // Check if upload configuration is available if (!uploadCfg?.url || !uploadCfg?.clientKey || !uploadCfg?.serviceId) { return { status: 400, dataStatus: 401, errorMessage: 'Missing upload configuration' }; } // Environment vars const allowSelfSignedCerts = getEnvVariableBool("ALLOW_SELF_SIGNED_CERTIFICATES", false); let url = getEnvVariable(uploadCfg.url || "", false); const clientKey = getEnvVariable(uploadCfg.clientKey || "", false); const serviceId = getEnvVariable(uploadCfg.serviceId || "", false); const dsfGtwKey = getEnvVariable(uploadCfg?.dsfgtwApiKey || "", ""); const method = (uploadCfg?.method || "PUT").toLowerCase(); // Check if the upload API is configured correctly if (!url || !clientKey) { return { status: 400, dataStatus: 402, errorMessage: 'Missing environment variables for upload' }; } // Construct the URL with tag being the elementName const tag = encodeURIComponent(elementName.trim()); url += `/${tag}`; // Get the page configuration using utility (safely extracts the correct page) const page = getPageConfigData(service, pageUrl); // Check if the page template is valid if (!page?.pageTemplate) { return { status: 400, dataStatus: 403, errorMessage: 'Invalid page configuration' }; } // ----- Conditional logic comes here // Respect conditional logic: If the page is skipped due to conditions, abort const conditionResult = evaluatePageConditions(page, store, siteId); if (conditionResult.result === false) { return { status: 403, dataStatus: 404, errorMessage: 'This page is skipped by conditional logic' }; } // deep copy the page template to avoid modifying the original const pageTemplateCopy = JSON.parse(JSON.stringify(page.pageTemplate)); // If mode is `single` make sure it has no multipleThings if (mode === "single" && page?.multipleThings) { return { status: 400, dataStatus: 413, errorMessage: 'Single mode upload not allowed on multipleThings pages' }; } // Validate the field: Only allow upload if the page contains a fileInput with the given name const isAllowed = pageContainsFileInput(pageTemplateCopy, elementName); if (!isAllowed) { return { status: 403, dataStatus: 405, errorMessage: `File input [${elementName}] not allowed on this page` }; } // Empty file check if (file.size === 0) { return { status: 400, dataStatus: 406, errorMessage: 'Uploaded file is empty' }; } // file type checks // 1. Check declared mimetype if (!ALLOWED_FILE_MIME_TYPES.includes(file.mimetype)) { return { status: 400, dataStatus: 407, errorMessage: 'Invalid file type (MIME not allowed)' }; } // 2. Check actual file content (magic bytes) matches claimed MIME type if (!isMagicByteValid(file.buffer, file.mimetype)) { return { status: 400, dataStatus: 408, errorMessage: 'Invalid file type (magic byte mismatch)' }; } // File size check if (file.size > ALLOWED_FILE_SIZE_MB * 1024 * 1024) { return { status: 400, dataStatus: 409, errorMessage: 'File exceeds allowed size' }; } // Prepare FormData const form = new FormData(); form.append('file', file.buffer, { filename: file.originalname, contentType: file.mimetype, }); logger.debug("Prepared FormData with file:", { filename: file.originalname, mimetype: file.mimetype, size: file.size }); // Get the user const user = dataLayer.getUser(store); // Perform the upload request const response = await govcyApiRequest( method, url, form, true, user, { accept: "text/plain", "client-key": clientKey, "service-id": serviceId, ...(dsfGtwKey !== "" && { "dsfgtw-api-key": dsfGtwKey }) }, 3, allowSelfSignedCerts ); // If not succeeded, handle error if (!response?.Succeeded) { return { status: 500, dataStatus: 410, errorMessage: `${response?.ErrorCode} - ${response?.ErrorMessage} - fileUploadAPIEndpoint returned succeeded false` }; } // Check if the response contains the expected data if (!response?.Data?.fileId || !response?.Data?.sha256) { return { status: 500, dataStatus: 411, errorMessage: 'Missing fileId or sha256 in response' }; } // ✅ Success // Store the file metadata in the session store const metadata = { sha256: response.Data.sha256, fileId: response.Data.fileId, fileName: response.Data.fileName || file.originalname || "", mimeType: response.Data.contentType || file.mimetype || "", fileSize: response.Data.fileSize || file.size || 0, }; if (mode === "multipleThingsDraft") { // Store in draft object let draft = dataLayer.getMultipleDraft(store, siteId, pageUrl); if (!draft) draft = {}; draft[elementName] = { sha256: response.Data.sha256, fileId: response.Data.fileId, }; dataLayer.setMultipleDraft(store, siteId, pageUrl, draft); logger.debug(`Stored file metadata in draft for ${siteId}/${pageUrl}`, metadata); } else if (mode === "multipleThingsEdit") { // Store in item array let items = dataLayer.getPageData(store, siteId, pageUrl); if (!Array.isArray(items)) items = []; if (index !== null && index >= 0 && index < items.length) { items[index][elementName] = { sha256: response.Data.sha256, fileId: response.Data.fileId, }; dataLayer.storePageData(store, siteId, pageUrl, items); logger.debug(`Stored file metadata in item index=${index} for ${siteId}/${pageUrl}`, metadata); } else { return { status: 400, dataStatus: 412, errorMessage: `Invalid index for multipleThingsEdit (index=${index})` }; } } else { // Default: single-page behaviour dataLayer.storePageDataElement(store, siteId, pageUrl, elementName, { sha256: response.Data.sha256, fileId: response.Data.fileId, }); logger.debug(`Stored file metadata in single mode for ${siteId}/${pageUrl}`, metadata); } return { status: 200, data: metadata }; } catch (err) { return { status: 500, dataStatus: 500, errorMessage: 'Upload failed' + (err.message ? `: ${err.message}` : ''), }; } } //-------------------------------------------------------------------------- // Helper Functions /** * Recursively checks whether any element (or its children) is a fileInput * with the matching elementName. * * Supports: * - Top-level fileInput * - Nested `params.elements` (used in groups, conditionals, etc.) * - Conditional radios/checkboxes with `items[].conditionalElements` * * @param {Array} elements - The array of elements to search * @param {string} targetName - The name of the file input to check * @returns {boolean} True if a matching fileInput is found, false otherwise */ function containsFileInput(elements = [], targetName) { for (const el of elements) { // ✅ Direct file input match if (el.element === 'fileInput' && el.params?.name === targetName) { return el; } // 🔁 Recurse into nested elements (e.g. groups, conditionals) if (Array.isArray(el?.params?.elements)) { const nestedMatch = containsFileInput(el.params.elements, targetName); if (nestedMatch) return nestedMatch; // ← propagate the found element } // 🎯 Special case: conditional radios/checkboxes if ( (el.element === 'radios' || el.element === 'checkboxes') && Array.isArray(el?.params?.items) ) { for (const item of el.params.items) { if (Array.isArray(item?.conditionalElements)) { const match = containsFileInput(item.conditionalElements, targetName); if (match) return match; // ← propagate the found element } } } } return false; } /** * Checks whether the specified page contains a valid fileInput for this element ID * under any <form> element in its sections * * @param {object} pageTemplate The page template object * @param {string} elementName The name of the element to check * @return {boolean} True if a fileInput exists, false otherwise */ export function pageContainsFileInput(pageTemplate, elementName) { const sections = pageTemplate?.sections || []; for (const section of sections) { for (const el of section?.elements || []) { if (el.element === 'form') { const match = containsFileInput(el.params?.elements, elementName); if (match) { return match; // ← return the actual element } } } } return null; // no match found } /** * Validates magic bytes against expected mimetype * @param {Buffer} buffer * @param {string} mimetype * @returns {boolean} */ export function isMagicByteValid(buffer, mimetype) { const signatures = { 'application/pdf': [0x25, 0x50, 0x44, 0x46], // %PDF 'image/png': [0x89, 0x50, 0x4E, 0x47], // PNG 'image/jpeg': [0xFF, 0xD8, 0xFF], // JPG/JPEG 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [0x50, 0x4B, 0x03, 0x04], // DOCX 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [0x50, 0x4B, 0x03, 0x04], // XLSX }; const expected = signatures[mimetype]; if (!expected) return false; // unknown type const actual = Array.from(buffer.slice(0, expected.length)); return expected.every((byte, i) => actual[i] === byte); }