@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
JavaScript
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);
}