@gov-cy/govcy-express-services 1.0.0-alpha.5 → 1.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gov-cy/govcy-express-services",
3
- "version": "1.0.0-alpha.5",
3
+ "version": "1.0.0-alpha.7",
4
4
  "description": "An Express-based system that dynamically renders services using @gov-cy/govcy-frontend-renderer and posts data to a submission API.",
5
5
  "author": "DMRID - DSF Team",
6
6
  "license": "MIT",
@@ -48,12 +48,14 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@gov-cy/dsf-email-templates": "^2.1.0",
51
- "@gov-cy/govcy-frontend-renderer": "^1.18.0",
51
+ "@gov-cy/govcy-frontend-renderer": "^1.20.0",
52
52
  "axios": "^1.9.0",
53
53
  "cookie-parser": "^1.4.7",
54
54
  "dotenv": "^16.3.1",
55
55
  "express": "^4.18.2",
56
56
  "express-session": "^1.17.3",
57
+ "form-data": "^4.0.4",
58
+ "multer": "^2.0.2",
57
59
  "openid-client": "^6.3.4",
58
60
  "puppeteer": "^24.6.0"
59
61
  },
package/src/index.mjs CHANGED
@@ -24,6 +24,7 @@ import { govcyManifestHandler } from './middleware/govcyManifestHandler.mjs';
24
24
  import { govcyRoutePageHandler } from './middleware/govcyRoutePageHandler.mjs';
25
25
  import { govcyServiceEligibilityHandler } from './middleware/govcyServiceEligibilityHandler.mjs';
26
26
  import { govcyLoadSubmissionData } from './middleware/govcyLoadSubmissionData.mjs';
27
+ import { govcyUploadMiddleware } from './middleware/govcyUpload.mjs';
27
28
  import { isProdOrStaging , getEnvVariable, whatsIsMyEnvironment } from './utils/govcyEnvVariables.mjs';
28
29
  import { logger } from "./utils/govcyLogger.mjs";
29
30
 
@@ -128,6 +129,14 @@ export default function initializeGovCyExpressService(){
128
129
 
129
130
  // 📝 -- ROUTE: Serve manifest.json dynamically for each site
130
131
  app.get('/:siteId/manifest.json', serviceConfigDataMiddleware, govcyManifestHandler());
132
+
133
+ // 🗃️ -- ROUTE: Handle POST requests for file uploads for a page.
134
+ app.post('/apis/:siteId/:pageUrl/upload',
135
+ serviceConfigDataMiddleware,
136
+ requireAuth, // UNCOMMENT
137
+ naturalPersonPolicy, // UNCOMMENT
138
+ govcyServiceEligibilityHandler(true), // UNCOMMENT
139
+ govcyUploadMiddleware);
131
140
 
132
141
  // 🏠 -- ROUTE: Handle route with only siteId (/:siteId or /:siteId/)
133
142
  app.get('/:siteId', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(true),govcyLoadSubmissionData(),govcyPageHandler(), renderGovcyPage());
@@ -150,7 +159,6 @@ export default function initializeGovCyExpressService(){
150
159
  // 👀📥 -- ROUTE: Handle POST requests (Form Submissions) based on siteId and pageUrl, using govcyFormsPostHandler middleware
151
160
  app.post('/:siteId/:pageUrl', serviceConfigDataMiddleware, requireAuth, naturalPersonPolicy, govcyServiceEligibilityHandler(true), govcyFormsPostHandler());
152
161
 
153
-
154
162
  // post for /:siteId/review
155
163
 
156
164
  // 🔹 Catch 404 errors (must be after all routes)
@@ -7,6 +7,8 @@
7
7
  import { getLoginUrl, handleCallback, getLogoutUrl } from '../auth/cyLoginAuth.mjs';
8
8
  import { logger } from "../utils/govcyLogger.mjs";
9
9
  import { handleMiddlewareError } from "../utils/govcyUtils.mjs";
10
+ import { errorResponse } from "../utils/govcyApiResponse.mjs";
11
+ import { isApiRequest } from '../utils/govcyApiDetection.mjs';
10
12
 
11
13
  /**
12
14
  * Middleware to check if the user is authenticated. If not, redirect to the login page.
@@ -17,6 +19,12 @@ import { handleMiddlewareError } from "../utils/govcyUtils.mjs";
17
19
  */
18
20
  export function requireAuth(req, res, next) {
19
21
  if (!req.session.user) {
22
+ if (isApiRequest(req)) {
23
+ const err = new Error("Unauthorized: user not authenticated");
24
+ err.status = 401;
25
+ return next(err);
26
+ }
27
+
20
28
  // Store the original URL before redirecting to login
21
29
  req.session.redirectAfterLogin = req.originalUrl;
22
30
  return res.redirect('/login');
@@ -1,5 +1,8 @@
1
1
 
2
2
  import { handleMiddlewareError } from "../utils/govcyUtils.mjs";
3
+ import { errorResponse } from '../utils/govcyApiResponse.mjs';
4
+ import { isApiRequest } from '../utils/govcyApiDetection.mjs';
5
+
3
6
  /**
4
7
  * Middleware to handle CSRF token generation and validation.
5
8
  *
@@ -14,7 +17,18 @@ export function govcyCsrfMiddleware(req, res, next) {
14
17
  }
15
18
 
16
19
  req.csrfToken = () => req.session.csrfToken;
17
-
20
+
21
+ if (
22
+ req.method === 'POST' &&
23
+ req.headers['content-type']?.includes('multipart/form-data') &&
24
+ isApiRequest(req)) {
25
+ const tokenFromHeader = req.get('X-CSRF-Token');
26
+ // UNCOMMENT
27
+ if (!tokenFromHeader || tokenFromHeader !== req.session.csrfToken) {
28
+ return res.status(400).json(errorResponse(403, 'Invalid CSRF token'));
29
+ }
30
+ return next();
31
+ }
18
32
  // Check token on POST requests
19
33
  if (req.method === 'POST') {
20
34
  const tokenFromBody = req.body._csrf;
@@ -43,7 +43,7 @@ export function govcyFormsPostHandler() {
43
43
  }
44
44
 
45
45
  // const formData = req.body; // Submitted data
46
- const formData = getFormData(formElement.params.elements, req.body); // Submitted data
46
+ const formData = getFormData(formElement.params.elements, req.body, req.session, siteId, pageUrl); // Submitted data
47
47
 
48
48
  // ☑️ Start validation from top-level form elements
49
49
  const validationErrors = validateFormElements(formElement.params.elements, formData);
@@ -3,6 +3,8 @@ import * as govcyResources from "../resources/govcyResources.mjs";
3
3
  import * as dataLayer from "../utils/govcyDataLayer.mjs";
4
4
  import { logger } from "../utils/govcyLogger.mjs";
5
5
  import { whatsIsMyEnvironment } from '../utils/govcyEnvVariables.mjs';
6
+ import { errorResponse } from '../utils/govcyApiResponse.mjs';
7
+ import { isApiRequest } from '../utils/govcyApiDetection.mjs';
6
8
 
7
9
  /**
8
10
  * Middleware function to handle HTTP errors and render appropriate error pages.
@@ -49,9 +51,8 @@ export function govcyHttpErrorHandler(err, req, res, next) {
49
51
 
50
52
  res.status(statusCode);
51
53
 
52
- // Return JSON if the request expects it
53
- if (req.headers.accept && req.headers.accept.includes("application/json")) {
54
- return res.json({ error: message });
54
+ if (isApiRequest(req)) {
55
+ return res.status(statusCode).json(errorResponse(statusCode, message));
55
56
  }
56
57
 
57
58
  // Render an error page for non-JSON requests
@@ -53,6 +53,9 @@ export function govcyPageHandler() {
53
53
  element.params.method = "POST";
54
54
  // ➕ Add CSRF token
55
55
  element.params.elements.push(govcyResources.csrfTokenInput(req.csrfToken()));
56
+ // // ➕ Add siteId and pageUrl to form data
57
+ // element.params.elements.push(govcyResources.siteAndPageInput(siteId, pageUrl, req.globalLang));
58
+ // ➕ Add govcyFormsJs script to the form
56
59
  element.params.elements.push(govcyResources.staticResources.elements["govcyFormsJs"]);
57
60
 
58
61
  // 🔍 Find the first button with `prototypeNavigate`
@@ -88,7 +91,7 @@ export function govcyPageHandler() {
88
91
  }
89
92
  //--------- End of Handle Validation Errors ---------
90
93
 
91
- populateFormData(element.params.elements, theData,validationErrors);
94
+ populateFormData(element.params.elements, theData,validationErrors, req.session, siteId, pageUrl, req.globalLang);
92
95
  // if there are validation errors, add an error summary
93
96
  if (validationErrors?.errorSummary?.length > 0) {
94
97
  element.params.elements.unshift(govcyResources.errorSummary(validationErrors.errorSummary));
@@ -0,0 +1,36 @@
1
+ import multer from 'multer';
2
+ import { logger } from '../utils/govcyLogger.mjs';
3
+ import { successResponse, errorResponse } from '../utils/govcyApiResponse.mjs';
4
+ import { ALLOWED_MULTER_FILE_SIZE_MB } from "../utils/govcyConstants.mjs";
5
+ import { handleFileUpload } from "../utils/govcyHandleFiles.mjs";
6
+
7
+ // Configure multer to store the file in memory (not disk) and limit the size to 10MB
8
+ const upload = multer({
9
+ storage: multer.memoryStorage(),
10
+ limits: { fileSize: ALLOWED_MULTER_FILE_SIZE_MB * 1024 * 1024 } // 10MB
11
+ });
12
+
13
+
14
+
15
+
16
+ export const govcyUploadMiddleware = [
17
+ upload.single('file'), // multer parses the uploaded file and stores it in req.file
18
+
19
+ async function govcyUploadHandler(req, res) {
20
+ const result = await handleFileUpload({
21
+ service: req.serviceData,
22
+ store: req.session,
23
+ siteId: req.params.siteId,
24
+ pageUrl: req.params.pageUrl,
25
+ elementName: req.body?.elementName,
26
+ file: req.file
27
+ });
28
+
29
+ if (result.status !== 200) {
30
+ logger.error("Upload failed", result);
31
+ return res.status(result.status).json(errorResponse(result.status, result.errorMessage || 'File upload failed'));
32
+ }
33
+
34
+ return res.json(successResponse(result.data));
35
+ }
36
+ ];
@@ -0,0 +1,152 @@
1
+ // 🔍 Select all file inputs that have the .govcy-file-upload class
2
+ const fileInputs = document.querySelectorAll('input[type="file"].govcy-file-upload');
3
+
4
+ // 🔁 Loop over each file input and attach a change event listener
5
+ fileInputs.forEach(input => {
6
+ input.addEventListener('change', async (event) => {
7
+ const messages = {
8
+ "uploadSuccesful": {
9
+ "el": "Το αρχείο ανεβαστηκε",
10
+ "en": "File uploaded successfully",
11
+ "tr": "File uploaded successfully"
12
+ },
13
+ "uploadFailed": {
14
+ "el": "Αποτυχια ανεβασης",
15
+ "en": "File upload failed",
16
+ "tr": "File upload failed"
17
+ }
18
+ };
19
+ // 🔐 Get the CSRF token from a hidden input field (generated by your backend)
20
+ const csrfToken = document.querySelector('input[type="hidden"][name="_csrf"]')?.value;
21
+ // 🔧 Define siteId and pageUrl (you can dynamically extract these later)
22
+ const siteId = window._govcySiteId || "";
23
+ const pageUrl = window._govcyPageUrl || "";
24
+ const lang = window._govcyLang || "el";
25
+ // 📦 Grab the selected file
26
+ const file = event.target.files[0];
27
+ const elementName = input.name; // Form field's `name` attribute
28
+
29
+ if (!file) return; // Exit if no file was selected
30
+
31
+ // 🧵 Prepare form-data payload for the API
32
+ const formData = new FormData();
33
+ formData.append('file', file); // Attach the actual file
34
+ formData.append('elementName', elementName); // Attach the field name for backend lookup
35
+
36
+ try {
37
+ // 🚀 Send file to the backend upload API
38
+ const response = await axios.post(`/apis/${siteId}/${pageUrl}/upload`, formData, {
39
+ headers: {
40
+ 'X-CSRF-Token': csrfToken // 🔐 Pass CSRF token in custom header
41
+ }
42
+ });
43
+
44
+ const { sha256, fileId } = response.data.Data;
45
+
46
+ // 📝 Store returned metadata in hidden fields for submission with the form
47
+ document.querySelector(`[name="${elementName}Attachment[fileId]"`).value = fileId;
48
+ document.querySelector(`[name="${elementName}Attachment[sha256]"`).value = sha256;
49
+
50
+ // ✅ Success
51
+ // Create an instance of GovcyFrontendRendererBrowser
52
+ const renderer = new GovcyFrontendRendererBrowser();
53
+ // Define the input data
54
+ const inputData =
55
+ {
56
+ "site": {
57
+ "lang": lang
58
+ }
59
+ };
60
+
61
+ const fileInputMap = window._govcyFileInputs || {};
62
+ let fileElement = fileInputMap[elementName];
63
+ fileElement.element = "fileView";
64
+ fileElement.params.fileId = fileId;
65
+ fileElement.params.sha256 = sha256;
66
+ fileElement.params.visuallyHiddenText = fileElement.params.label;
67
+ fileElement.params.error = null;
68
+ // TODO: Also need to set the `view` and `download` URLs
69
+ fileElement.params.viewHref = "#viewHref";
70
+ fileElement.params.deleteHref = "#deleteHref";
71
+ // Construct the JSONTemplate
72
+ const JSONTemplate = {
73
+ "elements": [fileElement]
74
+ };
75
+
76
+ //render HTML into string
77
+ let renderedHtml = renderer.renderFromJSON(JSONTemplate,inputData);
78
+ // look for element with id `${elementName}-outer-control`
79
+ // if not found look for element with id `${elementName}-input-control`
80
+ // if not found look for element with id `${elementName}-view-control`
81
+ var outerElement = document.getElementById(`${elementName}-outer-control`)
82
+ || document.getElementById(`${elementName}-input-control`)
83
+ || document.getElementById(`${elementName}-view-control`);
84
+
85
+ if (outerElement) {
86
+ //remove all classes from outerElement
87
+ outerElement.className = "";
88
+ //set the id of the outerElement to `${elementName}-outer-control`
89
+ outerElement.id = `${elementName}-outer-control`;
90
+ //update DOM and initialize the JS components
91
+ renderer.updateDOMAndInitialize(`${elementName}-outer-control`, renderedHtml);
92
+ }
93
+ // ✅ Update ARIA live region with success message
94
+ const statusRegion = document.getElementById('_govcy-upload-status');
95
+ if (statusRegion) {
96
+ statusRegion.textContent = messages.uploadSuccesful[lang];
97
+ setTimeout(() => {
98
+ statusRegion.textContent = '';
99
+ }, 10000);
100
+ }
101
+ // alert('✅ File uploaded successfully');
102
+
103
+ } catch (err) {
104
+ // Create an instance of GovcyFrontendRendererBrowser
105
+ const renderer = new GovcyFrontendRendererBrowser();
106
+ const lang = window._govcyLang || "el";
107
+ // Define the input data
108
+ const inputData =
109
+ {
110
+ "site": {
111
+ "lang": lang
112
+ }
113
+ };
114
+ const fileInputMap = window._govcyFileInputs || {};
115
+ let fileElement = fileInputMap[elementName];
116
+ fileElement.element = "fileInput";
117
+ fileElement.params.fileId = "";
118
+ fileElement.params.sha256 = ""
119
+ fileElement.params.error = messages.uploadFailed;
120
+
121
+ // Construct the JSONTemplate
122
+ const JSONTemplate = {
123
+ "elements": [fileElement]
124
+ };
125
+ //render HTML into string
126
+ let renderedHtml = renderer.renderFromJSON(JSONTemplate,inputData);
127
+ var outerElement = document.getElementById(`${elementName}-outer-control`)
128
+ || document.getElementById(`${elementName}-input-control`)
129
+ || document.getElementById(`${elementName}-view-control`);
130
+
131
+ if (outerElement) {
132
+ //remove all classes from outerElement
133
+ outerElement.className = "";
134
+ //set the id of the outerElement to `${elementName}-outer-control`
135
+ outerElement.id = `${elementName}-outer-control`;
136
+ //update DOM and initialize the JS components
137
+ renderer.updateDOMAndInitialize(`${elementName}-outer-control`, renderedHtml);
138
+ //TODO: Kamran need to figure a way to re register the DOM event on change
139
+ }
140
+ // ✅ Update ARIA live region with success message
141
+ const statusRegion = document.getElementById('_govcy-upload-error');
142
+ if (statusRegion) {
143
+ statusRegion.textContent = messages.uploadFailed[lang];
144
+ setTimeout(() => {
145
+ statusRegion.textContent = '';
146
+ }, 10000);
147
+ }
148
+ // // ⚠️ Show an error message if upload fails
149
+ // alert('❌ Upload failed: ' + (err.response?.data?.error || err.message));
150
+ }
151
+ });
152
+ });
@@ -113,9 +113,9 @@ export const staticResources = {
113
113
  element: "htmlElement",
114
114
  params: {
115
115
  text: {
116
- en: `<script src="/js/govcyForms.js"></script>`,
117
- el: `<script src="/js/govcyForms.js"></script>`,
118
- tr: `<script src="/js/govcyForms.js"></script>`
116
+ en: `<script src="https://cdn.jsdelivr.net/npm/axios@1.6.2/dist/axios.min.js"></script><script src="https://cdn.jsdelivr.net/gh/gov-cy/govcy-frontend-renderer@v1/dist/govcyCompiledTemplates.browser.js"></script><script src="https://cdn.jsdelivr.net/gh/gov-cy/govcy-frontend-renderer@v1/dist/govcyFrontendRenderer.browser.js"></script><script src="/js/govcyForms.js"></script><script src="/js/govcyFiles.js"></script>`,
117
+ el: `<script src="https://cdn.jsdelivr.net/npm/axios@1.6.2/dist/axios.min.js"></script><script src="https://cdn.jsdelivr.net/gh/gov-cy/govcy-frontend-renderer@v1/dist/govcyCompiledTemplates.browser.js"></script><script src="https://cdn.jsdelivr.net/gh/gov-cy/govcy-frontend-renderer@v1/dist/govcyFrontendRenderer.browser.js"></script><script src="/js/govcyForms.js"></script><script src="/js/govcyFiles.js"></script>`,
118
+ tr: `<script src="https://cdn.jsdelivr.net/npm/axios@1.6.2/dist/axios.min.js"></script><script src="https://cdn.jsdelivr.net/gh/gov-cy/govcy-frontend-renderer@v1/dist/govcyCompiledTemplates.browser.js"></script><script src="https://cdn.jsdelivr.net/gh/gov-cy/govcy-frontend-renderer@v1/dist/govcyFrontendRenderer.browser.js"></script><script src="/js/govcyForms.js"></script><script src="/js/govcyFiles.js"></script>`
119
119
  }
120
120
  }
121
121
  },
@@ -192,6 +192,27 @@ export function csrfTokenInput(csrfToken) {
192
192
  };
193
193
  }
194
194
 
195
+ /**
196
+ * Get the site and page input elements
197
+ * @param {string} siteId The site id
198
+ * @param {string} pageUrl The page url
199
+ * @param {string} lang The page language
200
+ * @returns {object} htmlElement with the site and page inputs
201
+ */
202
+ export function siteAndPageInput(siteId, pageUrl, lang = "el") {
203
+ const siteAndPageInputs = `<input type="hidden" name="_siteId" value="${siteId}"><input type="hidden" name="_pageUrl" value="${pageUrl}"><input type="hidden" name="_lang" value="${lang}">`;
204
+ return {
205
+ element: "htmlElement",
206
+ params: {
207
+ text: {
208
+ en: siteAndPageInputs,
209
+ el: siteAndPageInputs,
210
+ tr: siteAndPageInputs
211
+ }
212
+ }
213
+ };
214
+ }
215
+
195
216
  /**
196
217
  * Error page template
197
218
  * @param {object} title the title text element
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Determines if a request is targeting an API endpoint.
3
+ * Currently matches:
4
+ * - Accept header with application/json
5
+ * - URLs ending with /upload or /download under a site/page structure
6
+ *
7
+ * @param {object} req - Express request object
8
+ * @returns {boolean}
9
+ */
10
+ export function isApiRequest(req) {
11
+ const acceptJson = (req.headers?.accept || "").toLowerCase().includes("application/json");
12
+
13
+ const apiUrlPattern = /^\/apis\/[^/]+\/[^/]+\/(upload|download)$/;
14
+ const isStructuredApiUrl = apiUrlPattern.test(req.originalUrl || req.url);
15
+
16
+ return acceptJson || isStructuredApiUrl;
17
+ }
@@ -5,7 +5,7 @@ import { logger } from "./govcyLogger.mjs";
5
5
  * Utility to handle API communication with retry logic
6
6
  * @param {string} method - HTTP method (e.g., 'post', 'get', etc.)
7
7
  * @param {string} url - API endpoint URL
8
- * @param {object} inputData - Payload for the request (optional)
8
+ * @param {object|FormData} inputData - Payload for the request (optional)
9
9
  * @param {boolean} useAccessTokenAuth - Whether to use Authorization header with Bearer token
10
10
  * @param {object} user - User object containing access_token (optional)
11
11
  * @param {object} headers - Custom headers (optional)
@@ -39,6 +39,14 @@ export async function govcyApiRequest(
39
39
  requestHeaders['Authorization'] = `Bearer ${user.access_token}`;
40
40
  }
41
41
 
42
+ // If inputData is FormData, for attachments
43
+ if (inputData instanceof (await import('form-data')).default) {
44
+ requestHeaders = {
45
+ ...requestHeaders,
46
+ ...inputData.getHeaders(), // includes boundary in content-type
47
+ };
48
+ }
49
+
42
50
  while (attempt < retries) {
43
51
  try {
44
52
  logger.debug(`📤 Sending API request (Attempt ${attempt + 1})`, { method, url, inputData, requestHeaders });
@@ -47,13 +55,22 @@ export async function govcyApiRequest(
47
55
  const axiosConfig = {
48
56
  method,
49
57
  url,
50
- [method?.toLowerCase() === 'get' ? 'params' : 'data']: inputData,
58
+ ...(inputData instanceof (await import('form-data')).default // If inputData is FormData, for attachments
59
+ ? { data: inputData }
60
+ : { [method?.toLowerCase() === 'get' ? 'params' : 'data']: inputData }),
51
61
  headers: requestHeaders,
52
62
  timeout: 10000, // 10 seconds timeout
53
63
  // ✅ Treat only these statuses as "resolved" (no throw)
54
64
  validateStatus: (status) => allowedHTTPStatusCodes.includes(status),
55
65
  };
56
66
 
67
+ // If inputData is FormData, for attachments
68
+ if (inputData instanceof (await import('form-data')).default) {
69
+ axiosConfig.maxContentLength = Infinity;
70
+ axiosConfig.maxBodyLength = Infinity;
71
+ }
72
+
73
+
57
74
  // Add httpsAgent if NOT production to allow self-signed certificates
58
75
  // Use per-call config for self-signed certs
59
76
  if (allowSelfSignedCerts) {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The successResponse function creates a standardized success response object.
3
+ *
4
+ * @param {*} data - The data to be included in the response.
5
+ * @returns {Object} - The success response object.
6
+ */
7
+ export function successResponse(data = null) {
8
+ return {
9
+ Succeeded: true,
10
+ ErrorCode: 0,
11
+ ErrorMessage: '',
12
+ Data: data
13
+ };
14
+ }
15
+
16
+ /**
17
+ * The errorResponse function creates a standardized error response object.
18
+ *
19
+ * @param {int} code - The error code to be included in the response.
20
+ * @param {string} message - The error message to be included in the response.
21
+ * @param {Object} data - Additional data to be included in the response.
22
+ * @returns {Object} - The error response object.
23
+ */
24
+ export function errorResponse(code = 1, message = 'Unknown error', data = null) {
25
+ return {
26
+ Succeeded: false,
27
+ ErrorCode: code,
28
+ ErrorMessage: message,
29
+ Data: data
30
+ };
31
+ }
@@ -1,4 +1,8 @@
1
1
  /**
2
2
  * Shared constants for allowed form elements.
3
3
  */
4
- export const ALLOWED_FORM_ELEMENTS = ["textInput", "textArea", "select", "radios", "checkboxes", "datePicker", "dateInput"];
4
+ export const ALLOWED_FORM_ELEMENTS = ["textInput", "textArea", "select", "radios", "checkboxes", "datePicker", "dateInput","fileInput","fileView"];
5
+ export const ALLOWED_FILE_MIME_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
6
+ export const ALLOWED_FILE_EXTENSIONS = ['pdf', 'jpg', 'jpeg', 'png'];
7
+ export const ALLOWED_FILE_SIZE_MB = 5; // Maximum file size in MB
8
+ export const ALLOWED_MULTER_FILE_SIZE_MB = 10; // Maximum file size in MB
@@ -98,6 +98,14 @@ export function storePageData(store, siteId, pageUrl, formData) {
98
98
 
99
99
  store.siteData[siteId].inputData[pageUrl]["formData"] = formData;
100
100
  }
101
+
102
+ export function storePageDataElement(store, siteId, pageUrl, elementName, value) {
103
+ // Ensure session structure is initialized
104
+ initializeSiteData(store, siteId, pageUrl);
105
+
106
+ // Store the element value
107
+ store.siteData[siteId].inputData[pageUrl].formData[elementName] = value;
108
+ }
101
109
  /**
102
110
  * Stores the page's input data in the data layer
103
111
  * *
@@ -328,6 +336,20 @@ export function getSiteLoadData(store, siteId) {
328
336
  return null;
329
337
  }
330
338
 
339
+ /**
340
+ * Get the site's reference number from load data from the store
341
+ *
342
+ * @param {object} store The session store
343
+ * @param {string} siteId The site ID
344
+ * @returns {string|null} The reference number or null if not available
345
+ */
346
+ export function getSiteLoadDataReferenceNumber(store, siteId) {
347
+ const ref = store?.siteData?.[siteId]?.loadData?.referenceValue;
348
+
349
+ return typeof ref === 'string' && ref.trim() !== '' ? ref : null;
350
+ }
351
+
352
+
331
353
  /**
332
354
  * Get the site's input data from the store
333
355
  *
@@ -124,7 +124,7 @@ export function evaluateExpressionWithFlattening(expression, object, prefix = ''
124
124
  * @returns {{ result: true } | { result: false, redirect: string }}
125
125
  * An object indicating whether to render the page or redirect
126
126
  */
127
- export function evaluatePageConditions(page, store, siteKey, req) {
127
+ export function evaluatePageConditions(page, store, siteKey, req = {}) {
128
128
  // Get conditions array from nested page structure
129
129
  const conditions = page?.pageData?.conditions;
130
130
 
@@ -5,16 +5,22 @@
5
5
  * and show error summary when there are validation errors.
6
6
  */
7
7
  import { ALLOWED_FORM_ELEMENTS } from "./govcyConstants.mjs";
8
+ import * as dataLayer from "./govcyDataLayer.mjs";
8
9
 
9
10
 
10
11
  /**
11
12
  * Helper function to populate form data with session data
12
13
  * @param {Array} formElements The form elements
13
14
  * @param {*} theData The data either from session or request
15
+ * @param {Object} validationErrors The validation errors
16
+ * @param {Object} store The session store
17
+ * @param {string} siteId The site ID
18
+ * @param {string} pageUrl The page URL
19
+ * @param {string} lang The language
14
20
  */
15
- export function populateFormData(formElements, theData, validationErrors) {
21
+ export function populateFormData(formElements, theData, validationErrors, store = {}, siteId = "", pageUrl = "", lang = "el") {
16
22
  const inputElements = ALLOWED_FORM_ELEMENTS;
17
-
23
+ let fileInputElements = {};
18
24
  // Recursively populate form data with session data
19
25
  formElements.forEach(element => {
20
26
  if (inputElements.includes(element.element)) {
@@ -53,6 +59,24 @@ export function populateFormData(formElements, theData, validationErrors) {
53
59
  // Invalid format (not matching D/M/YYYY or DD/MM/YYYY)
54
60
  element.params.value = "";
55
61
  }
62
+ } else if (element.element === "fileInput") {
63
+ // For fileInput, we change the element.element to "fileView" and set the
64
+ // fileId and sha256 from the session store
65
+ const fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, fieldName + "Attachment");
66
+ // TODO: Ask Andreas how to handle empty file inputs
67
+ if (fileData) {
68
+ element.element = "fileView";
69
+ element.params.fileId = fileData.fileId;
70
+ element.params.sha256 = fileData.sha256;
71
+ element.params.visuallyHiddenText = element.params.label;
72
+ // TODO: Also need to set the `view` and `download` URLs
73
+ element.params.viewHref = "#viewHref";
74
+ element.params.deleteHref = "#deleteHref";
75
+ } else {
76
+ // TODO: Ask Andreas how to handle empty file inputs
77
+ element.params.value = "";
78
+ }
79
+ fileInputElements[fieldName] = element;
56
80
  // Handle all other input elements (textInput, checkboxes, radios, etc.)
57
81
  } else {
58
82
  element.params.value = theData[fieldName] || "";
@@ -88,6 +112,29 @@ export function populateFormData(formElements, theData, validationErrors) {
88
112
  });
89
113
  }
90
114
  });
115
+ // add file input elements's definition in js object
116
+ if (fileInputElements != {}) {
117
+ const scriptTag = `
118
+ <script type="text/javascript">
119
+ window._govcyFileInputs = ${JSON.stringify(fileInputElements)};
120
+ window._govcySiteId = "${siteId}";
121
+ window._govcyPageUrl = "${pageUrl}";
122
+ window._govcyLang = "${lang}";
123
+ </script>
124
+ <div id="_govcy-upload-status" class="govcy-visually-hidden" role="status" aria-live="polite"></div>
125
+ <div id="_govcy-upload-error" class="govcy-visually-hidden" role="alert" aria-live="assertive"></div>
126
+ `.trim();
127
+ formElements.push({
128
+ element: 'htmlElement',
129
+ params: {
130
+ text: {
131
+ en: scriptTag,
132
+ el: scriptTag,
133
+ tr: scriptTag
134
+ }
135
+ }
136
+ });
137
+ }
91
138
  }
92
139
 
93
140
 
@@ -96,9 +143,12 @@ export function populateFormData(formElements, theData, validationErrors) {
96
143
  *
97
144
  * @param {Array} elements - The form elements (including conditional ones).
98
145
  * @param {Object} formData - The submitted form data.
146
+ * @param {Object} store - The session store .
147
+ * @param {string} siteId - The site ID .
148
+ * @param {string} pageUrl - The page URL .
99
149
  * @returns {Object} filteredData - The filtered form data.
100
150
  */
101
- export function getFormData(elements, formData) {
151
+ export function getFormData(elements, formData, store = {}, siteId = "", pageUrl = "") {
102
152
  const filteredData = {};
103
153
  elements.forEach(element => {
104
154
  const { name } = element.params || {};
@@ -130,6 +180,17 @@ export function getFormData(elements, formData) {
130
180
  filteredData[`${name}_day`] = day !== undefined && day !== null ? day : "";
131
181
  filteredData[`${name}_month`] = month !== undefined && month !== null ? month : "";
132
182
  filteredData[`${name}_year`] = year !== undefined && year !== null ? year : "";
183
+ // handle fileInput
184
+ } else if (element.element === "fileInput") {
185
+ // fileInput elements are already stored in the store when it was uploaded
186
+ // so we just need to check if the file exists in the dataLayer in the store and add it the filteredData
187
+ const fileData = dataLayer.getFormDataValue(store, siteId, pageUrl, name + "Attachment");
188
+ if (fileData) {
189
+ filteredData[name + "Attachment"] = fileData;
190
+ } else {
191
+ //TODO: Ask Andreas how to handle empty file inputs
192
+ filteredData[name + "Attachment"] = ""; // or handle as needed
193
+ }
133
194
  // Handle other elements (e.g., textInput, textArea, datePicker)
134
195
  } else {
135
196
  filteredData[name] = formData[name] !== undefined && formData[name] !== null ? formData[name] : "";
@@ -0,0 +1,282 @@
1
+ import FormData from 'form-data';
2
+ import { getPageConfigData } from "./govcyLoadConfigData.mjs";
3
+ import { evaluatePageConditions } from "./govcyExpressions.mjs";
4
+ import { getEnvVariable, getEnvVariableBool } from "./govcyEnvVariables.mjs";
5
+ import { ALLOWED_FILE_MIME_TYPES, ALLOWED_FILE_SIZE_MB } from "./govcyConstants.mjs";
6
+ import { govcyApiRequest } from "./govcyApiRequest.mjs";
7
+ import * as dataLayer from "./govcyDataLayer.mjs";
8
+ import { logger } from './govcyLogger.mjs';
9
+
10
+ /**
11
+ * Handles the logic for uploading a file to the configured Upload API.
12
+ * Does not send a response — just returns a standard object to be handled by middleware.
13
+ *
14
+ * @param {object} opts - Input parameters
15
+ * @param {object} opts.service - The service config object
16
+ * @param {object} opts.store - Session store (req.session)
17
+ * @param {string} opts.siteId - Site ID
18
+ * @param {string} opts.pageUrl - Page URL
19
+ * @param {string} opts.elementName - Name of file input
20
+ * @param {object} opts.file - File object from multer (req.file)
21
+ * @returns {Promise<{ status: number, data?: object, errorMessage?: string }>}
22
+ */
23
+ export async function handleFileUpload({ service, store, siteId, pageUrl, elementName, file }) {
24
+ try {
25
+ // Validate essentials
26
+ // Early exit if key things are missing
27
+ if (!file || !elementName) {
28
+ return {
29
+ status: 400,
30
+ errorMessage: 'Missing file or element name'
31
+ };
32
+ }
33
+
34
+ // Get the upload configuration
35
+ const uploadCfg = service?.site?.fileUploadAPIEndpoint;
36
+ // Check if upload configuration is available
37
+ if (!uploadCfg?.url || !uploadCfg?.clientKey || !uploadCfg?.serviceId) {
38
+ return {
39
+ status: 400,
40
+ errorMessage: 'Missing upload configuration'
41
+ };
42
+ }
43
+
44
+ // Environment vars
45
+ const allowSelfSignedCerts = getEnvVariableBool("ALLOW_SELF_SIGNED_CERTIFICATES", false);
46
+ let url = getEnvVariable(uploadCfg.url || "", false);
47
+ const clientKey = getEnvVariable(uploadCfg.clientKey || "", false);
48
+ const serviceId = getEnvVariable(uploadCfg.serviceId || "", false);
49
+ const dsfGtwKey = getEnvVariable(uploadCfg?.dsfgtwApiKey || "", "");
50
+ const method = (uploadCfg?.method || "PUT").toLowerCase();
51
+
52
+ // Check if the upload API is configured correctly
53
+ if (!url || !clientKey) {
54
+ return {
55
+ status: 400,
56
+ errorMessage: 'Missing environment variables for upload'
57
+ };
58
+ }
59
+
60
+ // Construct the URL with tag being the elementName
61
+ const tag = encodeURIComponent(elementName.trim());
62
+ url += `/${tag}`;
63
+
64
+ // Get the page configuration using utility (safely extracts the correct page)
65
+ const page = getPageConfigData(service, pageUrl);
66
+ // Check if the page template is valid
67
+ if (!page?.pageTemplate) {
68
+ return {
69
+ status: 400,
70
+ errorMessage: 'Invalid page configuration'
71
+ };
72
+ }
73
+
74
+ // ----- Conditional logic comes here
75
+ // Respect conditional logic: If the page is skipped due to conditions, abort
76
+ const conditionResult = evaluatePageConditions(page, store, siteId);
77
+ if (conditionResult.result === false) {
78
+ return {
79
+ status: 403,
80
+ errorMessage: 'This page is skipped by conditional logic'
81
+ };
82
+ }
83
+
84
+ // deep copy the page template to avoid modifying the original
85
+ const pageTemplateCopy = JSON.parse(JSON.stringify(page.pageTemplate));
86
+ // Validate the field: Only allow upload if the page contains a fileInput with the given name
87
+ const isAllowed = pageContainsFileInput(pageTemplateCopy, elementName);
88
+ if (!isAllowed) {
89
+ return {
90
+ status: 403,
91
+ errorMessage: `File input [${elementName}] not allowed on this page`
92
+ };
93
+ }
94
+
95
+ // Empty file check
96
+ if (file.size === 0) {
97
+ return {
98
+ status: 400,
99
+ errorMessage: 'Uploaded file is empty'
100
+ };
101
+ }
102
+
103
+ // file type checks
104
+ // 1. Check declared mimetype
105
+ if (!ALLOWED_FILE_MIME_TYPES.includes(file.mimetype)) {
106
+ return {
107
+ status: 400,
108
+ errorMessage: 'Invalid file type (MIME not allowed)'
109
+ };
110
+ }
111
+
112
+ // 2. Check actual file content (magic bytes) matches claimed MIME type
113
+ if (!isMagicByteValid(file.buffer, file.mimetype)) {
114
+ return {
115
+ status: 400,
116
+ errorMessage: 'Invalid file type (magic byte mismatch)'
117
+ };
118
+ }
119
+
120
+ // File size check
121
+ if (file.size > ALLOWED_FILE_SIZE_MB * 1024 * 1024) {
122
+ return {
123
+ status: 400,
124
+ errorMessage: 'File exceeds allowed size'
125
+ };
126
+ }
127
+
128
+ // Prepare FormData
129
+ const form = new FormData();
130
+ form.append('file', file.buffer, {
131
+ filename: file.originalname,
132
+ contentType: file.mimetype,
133
+ });
134
+
135
+ logger.debug("Prepared FormData with file:", {
136
+ filename: file.originalname,
137
+ mimetype: file.mimetype,
138
+ size: file.size
139
+ });
140
+
141
+ // Get the user
142
+ const user = dataLayer.getUser(store);
143
+ // Perform the upload request
144
+ const response = await govcyApiRequest(
145
+ method,
146
+ url,
147
+ form,
148
+ true,
149
+ user,
150
+ {
151
+ accept: "text/plain",
152
+ "client-key": clientKey,
153
+ "service-id": serviceId,
154
+ ...(dsfGtwKey !== "" && { "dsfgtw-api-key": dsfGtwKey })
155
+ },
156
+ 3,
157
+ allowSelfSignedCerts
158
+ );
159
+
160
+ // If not succeeded, handle error
161
+ if (!response?.Succeeded) {
162
+ return {
163
+ status: 500,
164
+ errorMessage: `${response?.ErrorCode} - ${response?.ErrorMessage} - fileUploadAPIEndpoint returned succeeded false`
165
+ };
166
+ }
167
+
168
+ // Check if the response contains the expected data
169
+ if (!response?.Data?.fileId || !response?.Data?.sha256) {
170
+ return {
171
+ status: 500,
172
+ errorMessage: 'Missing fileId or sha256 in response'
173
+ };
174
+ }
175
+
176
+ // ✅ Success
177
+ // Store the file metadata in the session store
178
+ dataLayer.storePageDataElement(store, siteId, pageUrl, elementName+"Attachment", {
179
+ sha256: response.Data.sha256,
180
+ fileId: response.Data.fileId,
181
+ });
182
+ logger.debug("File upload successful", response.Data);
183
+ logger.info(`File uploaded successfully for element ${elementName} on page ${pageUrl} for site ${siteId}`);
184
+ return {
185
+ status: 200,
186
+ data: {
187
+ sha: response.Data.sha256,
188
+ filename: response.Data.fileName || '',
189
+ fileId: response.Data.fileId,
190
+ mimeType: response.Data.contentType || '',
191
+ sha256: response.Data.fileSize || ''
192
+ }
193
+ };
194
+
195
+ } catch (err) {
196
+ return {
197
+ status: 500,
198
+ errorMessage: 'Upload failed' + (err.message ? `: ${err.message}` : ''),
199
+ };
200
+ }
201
+ }
202
+
203
+ //--------------------------------------------------------------------------
204
+ // Helper Functions
205
+ /**
206
+ * Recursively checks whether any element (or its children) is a fileInput
207
+ * with the matching elementName.
208
+ *
209
+ * Supports:
210
+ * - Top-level fileInput
211
+ * - Nested `params.elements` (used in groups, conditionals, etc.)
212
+ * - Conditional radios/checkboxes with `items[].conditionalElements`
213
+ *
214
+ * @param {Array} elements - The array of elements to search
215
+ * @param {string} targetName - The name of the file input to check
216
+ * @returns {boolean} True if a matching fileInput is found, false otherwise
217
+ */
218
+ function containsFileInput(elements = [], targetName) {
219
+ for (const el of elements) {
220
+ // ✅ Direct file input match
221
+ if (el.element === 'fileInput' && el.params?.name === targetName) {
222
+ return true;
223
+ }
224
+ // 🔁 Recurse into nested elements (e.g. groups, conditionals)
225
+ if (Array.isArray(el?.params?.elements)) {
226
+ if (containsFileInput(el.params.elements, targetName)) return true;
227
+ }
228
+
229
+ // 🎯 Special case: conditional radios/checkboxes
230
+ if (
231
+ (el.element === 'radios' || el.element === 'checkboxes') &&
232
+ Array.isArray(el?.params?.items)
233
+ ) {
234
+ for (const item of el.params.items) {
235
+ if (Array.isArray(item?.conditionalElements)) {
236
+ if (containsFileInput(item.conditionalElements, targetName)) return true;
237
+ }
238
+ }
239
+ }
240
+ }
241
+ return false;
242
+ }
243
+
244
+ /**
245
+ * Checks whether the specified page contains a valid fileInput for this element ID
246
+ * under any <form> element in its sections
247
+ *
248
+ * @param {object} pageTemplate The page template object
249
+ * @param {string} elementName The name of the element to check
250
+ * @return {boolean} True if a fileInput exists, false otherwise
251
+ */
252
+ function pageContainsFileInput(pageTemplate, elementName) {
253
+ const sections = pageTemplate?.sections || [];
254
+ return sections.some(section =>
255
+ section?.elements?.some(el =>
256
+ el.element === 'form' &&
257
+ containsFileInput(el.params?.elements, elementName)
258
+ )
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Validates magic bytes against expected mimetype
264
+ * @param {Buffer} buffer
265
+ * @param {string} mimetype
266
+ * @returns {boolean}
267
+ */
268
+ function isMagicByteValid(buffer, mimetype) {
269
+ const signatures = {
270
+ 'application/pdf': [0x25, 0x50, 0x44, 0x46], // %PDF
271
+ 'image/png': [0x89, 0x50, 0x4E, 0x47], // PNG
272
+ 'image/jpeg': [0xFF, 0xD8, 0xFF], // JPG/JPEG
273
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': [0x50, 0x4B, 0x03, 0x04], // DOCX
274
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': [0x50, 0x4B, 0x03, 0x04], // XLSX
275
+ };
276
+
277
+ const expected = signatures[mimetype];
278
+ if (!expected) return false; // unknown type
279
+
280
+ const actual = Array.from(buffer.slice(0, expected.length));
281
+ return expected.every((byte, i) => actual[i] === byte);
282
+ }
@@ -263,6 +263,8 @@ export function validateFormElements(elements, formData, pageUrl) {
263
263
  formData[`${field.params.name}_day`]]
264
264
  .filter(Boolean) // Remove empty values
265
265
  .join("-") // Join remaining parts
266
+ : (field.element === "fileInput") // Handle fileInput
267
+ ? formData[`${field.params.name}Attachment`] || ""
266
268
  : formData[field.params.name] || ""; // Get submitted value
267
269
 
268
270
  //Autocheck: check for "checkboxes", "radios", "select" if `fieldValue` is one of the `field.params.items
@@ -310,6 +312,8 @@ export function validateFormElements(elements, formData, pageUrl) {
310
312
  formData[`${conditionalElement.params.name}_day`]]
311
313
  .filter(Boolean) // Remove empty values
312
314
  .join("-") // Join remaining parts
315
+ : (conditionalElement.element === "fileInput") // Handle fileInput
316
+ ? formData[`${conditionalElement.params.name}Attachment`] || ""
313
317
  : formData[conditionalElement.params.name] || ""; // Get submitted value
314
318
 
315
319
  //Autocheck: check for "checkboxes", "radios", "select" if `fieldValue` is one of the `field.params.items`