@gov-cy/govcy-express-services 0.1.6 → 0.1.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": "0.1.
|
|
3
|
+
"version": "0.1.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",
|
|
@@ -3,6 +3,7 @@ import { populateFormData } from "../utils/govcyFormHandling.mjs";
|
|
|
3
3
|
import * as govcyResources from "../resources/govcyResources.mjs";
|
|
4
4
|
import * as dataLayer from "../utils/govcyDataLayer.mjs";
|
|
5
5
|
import { logger } from "../utils/govcyLogger.mjs";
|
|
6
|
+
// import {flattenContext, evaluateExpressionWithFlattening, evaluatePageConditions } from "../utils/govcyExpressions.mjs";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Middleware to handle page rendering and form processing
|
|
@@ -30,7 +31,8 @@ export function govcyPageHandler() {
|
|
|
30
31
|
// Deep copy pageTemplate to avoid modifying the original
|
|
31
32
|
const pageTemplateCopy = JSON.parse(JSON.stringify(page.pageTemplate));
|
|
32
33
|
|
|
33
|
-
//
|
|
34
|
+
// TODO: Conditional logic comes here
|
|
35
|
+
//if user is logged in add the user nane section in the page template
|
|
34
36
|
if (dataLayer.getUser(req.session)) {
|
|
35
37
|
pageTemplateCopy.sections.push(govcyResources.userNameSection(dataLayer.getUser(req.session).name)); // Add user name section
|
|
36
38
|
}
|
|
@@ -246,6 +246,23 @@ export function getSiteSubmissionErrors(store, siteId) {
|
|
|
246
246
|
return null;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Get the site's data from the store (including input data and eligibility data)
|
|
251
|
+
*
|
|
252
|
+
* @param {object} store The session store
|
|
253
|
+
* @param {string} siteId |The site id
|
|
254
|
+
* @returns The site data or null if none exist.
|
|
255
|
+
*/
|
|
256
|
+
export function getSiteData(store, siteId) {
|
|
257
|
+
const inputData = store?.siteData?.[siteId] || {};
|
|
258
|
+
|
|
259
|
+
if (inputData) {
|
|
260
|
+
return inputData;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
249
266
|
/**
|
|
250
267
|
* Get the site's input data from the store
|
|
251
268
|
*
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module govcyExpressions
|
|
3
|
+
*
|
|
4
|
+
* This module provides utility functions for evaluating JavaScript expressions
|
|
5
|
+
* with a flattened context, ensuring safe execution and preventing unsafe patterns.
|
|
6
|
+
*
|
|
7
|
+
*/
|
|
8
|
+
import { logger } from "../utils/govcyLogger.mjs";
|
|
9
|
+
import * as dataLayer from "../utils/govcyDataLayer.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively flattens nested objects into dot-path keys.
|
|
13
|
+
* E.g. { a: { b: { c: 5 } } } → { "a.b.c": 5 }
|
|
14
|
+
*
|
|
15
|
+
* Example usage:
|
|
16
|
+
* ```javascript
|
|
17
|
+
* flattenContext(req.session.siteData["testService"],"testService")
|
|
18
|
+
* ```
|
|
19
|
+
* This will output:
|
|
20
|
+
* ```json
|
|
21
|
+
* {
|
|
22
|
+
"testService.inputData.index.formData.certificate_select": [
|
|
23
|
+
"birth",
|
|
24
|
+
"permanent_residence"
|
|
25
|
+
],
|
|
26
|
+
"testService.inputData.page1.formData.mobile_select": "mobile",
|
|
27
|
+
"testService.inputData.page1.formData.mobileTxt": "",
|
|
28
|
+
"testService.inputData.page2.formData.mobile": "+35722404383",
|
|
29
|
+
"testService.inputData.page2.formData.dateGot_day": "12",
|
|
30
|
+
"testService.inputData.page2.formData.dateGot_month": "10",
|
|
31
|
+
"testService.inputData.page2.formData.dateGot_year": "1212",
|
|
32
|
+
"testService.inputData.page2.formData.appointment": "10/06/2025"
|
|
33
|
+
}
|
|
34
|
+
*
|
|
35
|
+
* @param {object} obj - The object to flatten
|
|
36
|
+
* @param {string} prefix - The prefix for nested keys (used internally)
|
|
37
|
+
* @param {object} res - The result object to store flattened keys (used internally)
|
|
38
|
+
* @returns {object} - Flattened object with dot-path keys
|
|
39
|
+
*/
|
|
40
|
+
export function flattenContext(obj, prefix = '', res = {}) {
|
|
41
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
42
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
43
|
+
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
44
|
+
flattenContext(val, path, res);
|
|
45
|
+
} else {
|
|
46
|
+
res[path] = val;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return res;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validates a JavaScript expression string against a blocklist of unsafe patterns.
|
|
54
|
+
* Throws an error if any unsafe pattern is found.
|
|
55
|
+
*
|
|
56
|
+
* @throws {Error} If the expression contains any unsafe patterns
|
|
57
|
+
* @param {string} expression - The JavaScript expression to validate
|
|
58
|
+
* @returns {void}
|
|
59
|
+
*/
|
|
60
|
+
function validateExpression(expression) {
|
|
61
|
+
const blocklist = [
|
|
62
|
+
'while(', 'for(', 'do{', // loops
|
|
63
|
+
'Function(', 'constructor(', // access to internals
|
|
64
|
+
'process', 'require', // Node.js access
|
|
65
|
+
'global', 'this', 'eval', // execution context or scope tricks
|
|
66
|
+
'__proto__', 'prototype', // prototype chain abuse
|
|
67
|
+
'setTimeout', 'setInterval', // async/loops
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
for (const forbidden of blocklist) {
|
|
71
|
+
if (expression.includes(forbidden)) {
|
|
72
|
+
logger.debug(`Blocked unsafe expression: contains "${forbidden}"`, expression);
|
|
73
|
+
throw new Error(`Blocked unsafe expression: contains "${forbidden}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Evaluates a JavaScript expression string with a given context.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} expression - The JavaScript expression to evaluate
|
|
83
|
+
* @param {object} dataLayer - The context object containing data for evaluation
|
|
84
|
+
* @throws {Error} If the expression is invalid or contains unsafe patterns
|
|
85
|
+
* @returns
|
|
86
|
+
*/
|
|
87
|
+
export function evaluateExpression(expression, dataLayer = {}) {
|
|
88
|
+
validateExpression(expression); // ⛔️ Blocks known unsafe patterns
|
|
89
|
+
|
|
90
|
+
// The expression will reference: dataLayer["some.path.key"]
|
|
91
|
+
console.debug(`Evaluating expression: ${expression}`, dataLayer);
|
|
92
|
+
const fn = new Function('dataLayer', `return (${expression});`);
|
|
93
|
+
return fn(dataLayer);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Evaluates expression **with automatic flattening**.
|
|
99
|
+
* This is a convenience wrapper for most use cases.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} expression - JS expression using dataLayer["..."]
|
|
102
|
+
* @param {object} object - Unflattened data object (e.g., session.siteData[siteKey])
|
|
103
|
+
* @param {string} prefix - Prefix to add to all keys (usually the siteKey)
|
|
104
|
+
* @returns {*} - The evaluated result
|
|
105
|
+
*/
|
|
106
|
+
export function evaluateExpressionWithFlattening(expression, object, prefix = '') {
|
|
107
|
+
const dataLayer = flattenContext(object, prefix);
|
|
108
|
+
return evaluateExpression(expression, dataLayer);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Evaluates whether a page should be rendered or redirected based on its conditions.
|
|
114
|
+
*
|
|
115
|
+
* - Conditions are located in `page.pageData.conditions`
|
|
116
|
+
* - Each condition must have a valid `expression` and `redirect`
|
|
117
|
+
* - If an expression evaluates to `true`, the user is redirected
|
|
118
|
+
* - If all expressions fail (or none exist), the page is rendered
|
|
119
|
+
*
|
|
120
|
+
* @param {object} page - The page object containing `pageData.conditions`
|
|
121
|
+
* @param {object} store - The full session object (e.g., `req.session`)
|
|
122
|
+
* @param {string} siteKey - The key for the current site context (e.g., `"nsf2"`)
|
|
123
|
+
* @param {object} req - The request object (used to track redirect depth)
|
|
124
|
+
* @returns {{ result: true } | { result: false, redirect: string }}
|
|
125
|
+
* An object indicating whether to render the page or redirect
|
|
126
|
+
*/
|
|
127
|
+
export function evaluatePageConditions(page, store, siteKey, req) {
|
|
128
|
+
// Get conditions array from nested page structure
|
|
129
|
+
const conditions = page?.pageData?.conditions;
|
|
130
|
+
|
|
131
|
+
// --- Protect against infinite redirects ---
|
|
132
|
+
const depth = req?._pageRedirectDepth || 0; // Track redirect depth per request (resets every HTTP call)
|
|
133
|
+
if (depth > 10) { // Safer default max limit
|
|
134
|
+
logger.debug(`Max redirect depth exceeded for siteKey '${siteKey}'`);
|
|
135
|
+
return { result: true };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// If no conditions or not an array, render the page by default
|
|
139
|
+
if (!Array.isArray(conditions) || conditions.length === 0) {
|
|
140
|
+
logger.debug(`No conditions found for page '${page.pageData?.url || page.id}'`);
|
|
141
|
+
return { result: true };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Get scoped session data for this site
|
|
145
|
+
const siteData = dataLayer.getSiteData(store, siteKey);
|
|
146
|
+
if (!siteData || typeof siteData !== 'object') {
|
|
147
|
+
logger.debug(`No site data found for siteKey '${siteKey}' on page '${page.pageData?.url || page.id}'`);
|
|
148
|
+
return { result: true }; // If site data is missing, continue safely
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Evaluate each condition
|
|
152
|
+
for (const [i, condition] of conditions.entries()) {
|
|
153
|
+
// Ensure the condition is well-formed
|
|
154
|
+
const isValid =
|
|
155
|
+
typeof condition === 'object' &&
|
|
156
|
+
condition !== null &&
|
|
157
|
+
typeof condition.expression === 'string' &&
|
|
158
|
+
condition.expression.trim() !== '' &&
|
|
159
|
+
typeof condition.redirect === 'string' &&
|
|
160
|
+
condition.redirect.trim() !== '';
|
|
161
|
+
|
|
162
|
+
if (!isValid) {
|
|
163
|
+
logger.debug(`Skipping invalid condition at index ${i} on page '${page.pageData?.url || page.id}'`, condition);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Evaluate the expression using flattened site data
|
|
169
|
+
const result = evaluateExpressionWithFlattening(
|
|
170
|
+
condition.expression,
|
|
171
|
+
siteData,
|
|
172
|
+
siteKey
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// ✅ If expression is true → trigger redirect
|
|
176
|
+
if (result === true) { // Only redirect if explicitly `true`
|
|
177
|
+
// --- Protect against infinite redirects ---
|
|
178
|
+
req._pageRedirectDepth = depth + 1; // Increment redirect counter only if redirecting
|
|
179
|
+
// return redirect response
|
|
180
|
+
return { result: false, redirect: condition.redirect };
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
logger.debug(`Expression error in condition[${i}] on page '${page.pageData?.url || page.id}':`, condition.expression);
|
|
184
|
+
logger.debug('→', err.message);
|
|
185
|
+
// ❌ Expression threw an error — skip and evaluate next
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ✅ All expressions were false or skipped → allow page rendering
|
|
190
|
+
return { result: true };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
// console.log(evaluateExpression(
|
|
196
|
+
// '(dataLayer["test.inputData.index.formData.certificate_select"] == "permanent_residence") || (dataLayer["test.eligibility.TEST_ELIGIBILITY_2_API_URL.result.Succeeded"] == false)',
|
|
197
|
+
// flattenContext(req.session.siteData.test,'test')));
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
// console.log(evaluatePageConditions(page, req.session, siteId));
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
// {
|
|
204
|
+
// "pageData": {
|
|
205
|
+
// "url": "data-entry-textinput",
|
|
206
|
+
// "title": {
|
|
207
|
+
// "el": "Σταθερό τηλέφωνο",
|
|
208
|
+
// "en": "Landline number",
|
|
209
|
+
// "tr": ""
|
|
210
|
+
// },
|
|
211
|
+
// "layout": "layouts/govcyBase.njk",
|
|
212
|
+
// "mainLayout": "two-third",
|
|
213
|
+
// "nextPage": "data-entry-all",
|
|
214
|
+
// "conditions": [
|
|
215
|
+
// {
|
|
216
|
+
// "expression": "dataLayer['test.eligibility.TEST_ELIGIBILITY_1_API_URL.result.Succeeded'] == false || dataLayer['test.inputData.data-entry-radios.formData.mobile_select'] == 'mobile'",
|
|
217
|
+
// "redirect": "review"
|
|
218
|
+
// },
|
|
219
|
+
// {
|
|
220
|
+
// "expression": "dataLayer['test.eligibility.TEST_ELIGIBILITY_1_API_URL.result.Succeeded'] == false",
|
|
221
|
+
// "redirect": "review"
|
|
222
|
+
// }
|
|
223
|
+
// ]
|
|
224
|
+
// },
|
|
225
|
+
|
|
226
|
+
// Todo:
|
|
227
|
+
// - [X] Add unit tests
|
|
228
|
+
// - [ ] Add page with conditions in JSON
|
|
229
|
+
// - [ ] Add logic on the page handler to evaluate conditions
|
|
230
|
+
// - [ ] Add logic on review and review post page handler to evaluate the conditions
|
|
231
|
+
// - [ ] Add Functional tests for the conditions
|
|
232
|
+
// - [ ] Add documentation for the conditions
|