PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.9
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.9
6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 3.6.65 All 196 releases
fluentform / app / Services / FormBuilder / DateConfigNormalizer.php

DateConfigNormalizer.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.9, at app/Services/FormBuilder/DateConfigNormalizer.php

355 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Services\FormBuilder;
4
5 /**
6 * Normalises the Date/Time field's Advanced Date Configuration into
7 * data-only JSON and regenerates the emitted flatpickr JS from it.
8 *
9 * Security invariant (see docs/SECURITY-REGRESSION-CHECKLIST.md): user
10 * input never reaches the script sink. Recognised dynamic snippets are
11 * converted into data tokens (__ff_fp_incr, __ff_date, __ff_disable_expr,
12 * legacy __ff_disable_days); toJs() rebuilds code exclusively from
13 * whitelisted enums, int casts, and wp_json_encode()d values. Anything
14 * unrecognised fails json_decode and rejects the whole config.
15 */
16 class DateConfigNormalizer
17 {
18 /**
19 * Encode flags for every user-influenced string/key that reaches the inline
20 * <script> sink. JSON_HEX_TAG/AMP/APOS/QUOT turn <, >, &, ', " into \uXXXX so
21 * no raw angle bracket can trigger the HTML5 script-data-double-escaped state
22 * (which fluentform_kses_js() alone does not fully close). Slashes stay escaped
23 * because the unescaped-slashes flag is never added here. Legit date/locale
24 * values contain none of these characters, so their emitted output is unchanged.
25 */
26 const JS_STRING_FLAGS = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT;
27
28 public static function sanitize($value)
29 {
30 if (!is_string($value) || '' === trim($value)) {
31 return '';
32 }
33
34 $value = trim($value);
35
36 $decoded = json_decode($value, true);
37
38 if (JSON_ERROR_NONE !== json_last_error() || !is_array($decoded)) {
39 $decoded = json_decode(static::normalizeJsObject($value), true);
40 }
41
42 if (JSON_ERROR_NONE !== json_last_error() || !is_array($decoded)) {
43 return '';
44 }
45
46 if ([] === $decoded) {
47 return '{}';
48 }
49
50 // date_config must be an object; reject a top-level JSON array. Nested
51 // arrays (e.g. flatpickr `disable: [...]`) are preserved by not forcing
52 // JSON_FORCE_OBJECT recursively.
53 if (array_keys($decoded) === range(0, count($decoded) - 1)) {
54 return '';
55 }
56
57 return wp_json_encode($decoded);
58 }
59
60 public static function normalizeJsObject($value)
61 {
62 $value = static::stripComments($value);
63
64 $value = preg_replace_callback(
65 '/new\s+Date\s*\(\s*(["\'])([^"\']{1,40})\1\s*\)/',
66 function ($m) {
67 if (!preg_match('/^[0-9A-Za-z ,:.\\/\\-]+$/', $m[2])) {
68 return $m[0];
69 }
70 return '{"__ff_date": ' . wp_json_encode($m[2]) . '}';
71 },
72 $value
73 );
74
75 $value = preg_replace_callback(
76 '/new\s+Date\s*\(\s*\)\s*(?:\.\s*fp_incr\s*\(\s*(-?\d+)\s*\)\s*)?/',
77 function ($m) {
78 $days = isset($m[1]) && '' !== $m[1] ? (int) $m[1] : 0;
79 return '{"__ff_fp_incr": ' . $days . '}';
80 },
81 $value
82 );
83
84 $value = preg_replace_callback(
85 '/function\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\)\s*\{\s*return\b\s*(.*?)\s*;?\s*\}/s',
86 function ($m) {
87 $expr = static::parsePredicate($m[2], $m[1]);
88 return $expr ? wp_json_encode(['__ff_disable_expr' => $expr]) : $m[0];
89 },
90 $value
91 );
92
93 // Single-quoted strings -> double-quoted (respecting escapes).
94 $value = preg_replace_callback(
95 "/'((?:\\\\.|[^'\\\\])*)'/s",
96 function ($m) {
97 return '"' . str_replace(['\\\'', '"'], ['\'', '\\"'], $m[1]) . '"';
98 },
99 $value
100 );
101
102 // Quote unquoted object keys: `{ key:` / `, key:` -> `{ "key":`.
103 $value = preg_replace('/([{,]\s*)([A-Za-z_$][A-Za-z0-9_$]*)(\s*:)/', '$1"$2"$3', $value);
104
105 // Drop trailing commas before a closing brace/bracket.
106 $value = preg_replace('/,\s*([}\]])/', '$1', $value);
107
108 return $value;
109 }
110
111 public static function stripComments($value)
112 {
113 $result = '';
114 $length = strlen($value);
115 $inString = '';
116
117 for ($i = 0; $i < $length; $i++) {
118 $char = $value[$i];
119
120 if ($inString) {
121 $result .= $char;
122 if ('\\' === $char) {
123 if ($i + 1 < $length) {
124 $result .= $value[++$i];
125 }
126 } elseif ($char === $inString) {
127 $inString = '';
128 }
129 continue;
130 }
131
132 if ('"' === $char || "'" === $char) {
133 $inString = $char;
134 $result .= $char;
135 continue;
136 }
137
138 if ('/' === $char && $i + 1 < $length) {
139 if ('/' === $value[$i + 1]) {
140 while ($i < $length && "\n" !== $value[$i]) {
141 $i++;
142 }
143 $result .= "\n";
144 continue;
145 }
146 if ('*' === $value[$i + 1]) {
147 $i += 2;
148 while ($i + 1 < $length && !('*' === $value[$i] && '/' === $value[$i + 1])) {
149 $i++;
150 }
151 $i++;
152 continue;
153 }
154 }
155
156 $result .= $char;
157 }
158
159 return $result;
160 }
161
162 public static function parsePredicate($body, $param)
163 {
164 $tokens = [];
165 $pos = 0;
166 $length = strlen($body);
167 $leaf = '/\G' . preg_quote($param, '/') . '\s*\.\s*(getDay|getMonth|getDate|getFullYear)\s*\(\s*\)\s*(===|==|!==|!=|>=|<=|>|<)\s*(\d{1,4})/';
168
169 while ($pos < $length) {
170 $char = $body[$pos];
171 if (ctype_space($char)) {
172 $pos++;
173 continue;
174 }
175 if ('(' === $char || ')' === $char) {
176 $tokens[] = $char;
177 $pos++;
178 continue;
179 }
180 if ('|' === $char || '&' === $char) {
181 if ($pos + 1 >= $length || $body[$pos + 1] !== $char) {
182 return null;
183 }
184 $tokens[] = '|' === $char ? '||' : '&&';
185 $pos += 2;
186 continue;
187 }
188 if (preg_match($leaf, $body, $m, 0, $pos)) {
189 $tokens[] = ['fn' => $m[1], 'cmp' => $m[2], 'val' => (int) $m[3]];
190 $pos += strlen($m[0]);
191 continue;
192 }
193 return null;
194 }
195
196 if ([] === $tokens) {
197 return null;
198 }
199
200 $index = 0;
201 $ast = self::parseOr($tokens, $index);
202
203 return null !== $ast && count($tokens) === $index ? $ast : null;
204 }
205
206 private static function parseOr(array $tokens, &$index)
207 {
208 $parts = [];
209 do {
210 $node = self::parseAnd($tokens, $index);
211 if (null === $node) {
212 return null;
213 }
214 $parts[] = $node;
215 $more = isset($tokens[$index]) && '||' === $tokens[$index];
216 if ($more) {
217 ++$index;
218 }
219 } while ($more);
220 return 1 === count($parts) ? $parts[0] : ['or' => $parts];
221 }
222
223 private static function parseAnd(array $tokens, &$index)
224 {
225 $parts = [];
226 do {
227 $node = self::parseUnit($tokens, $index);
228 if (null === $node) {
229 return null;
230 }
231 $parts[] = $node;
232 $more = isset($tokens[$index]) && '&&' === $tokens[$index];
233 if ($more) {
234 ++$index;
235 }
236 } while ($more);
237 return 1 === count($parts) ? $parts[0] : ['and' => $parts];
238 }
239
240 private static function parseUnit(array $tokens, &$index)
241 {
242 if (!isset($tokens[$index])) {
243 return null;
244 }
245 if ('(' === $tokens[$index]) {
246 ++$index;
247 $node = self::parseOr($tokens, $index);
248 if (null === $node || !isset($tokens[$index]) || ')' !== $tokens[$index]) {
249 return null;
250 }
251 ++$index;
252 return $node;
253 }
254 if (is_array($tokens[$index])) {
255 return $tokens[$index++];
256 }
257 return null;
258 }
259
260 public static function exprToJs($node)
261 {
262 static $fns = ['getDay' => 1, 'getMonth' => 1, 'getDate' => 1, 'getFullYear' => 1];
263 static $cmps = ['==' => 1, '===' => 1, '!=' => 1, '!==' => 1, '>=' => 1, '<=' => 1, '>' => 1, '<' => 1];
264
265 if (!is_array($node)) {
266 return null;
267 }
268
269 foreach (['or' => ' || ', 'and' => ' && '] as $op => $glue) {
270 if (isset($node[$op])) {
271 if (1 !== count($node) || !is_array($node[$op]) || [] === $node[$op]) {
272 return null;
273 }
274 $parts = [];
275 foreach ($node[$op] as $child) {
276 $js = static::exprToJs($child);
277 if (null === $js) {
278 return null;
279 }
280 $parts[] = $js;
281 }
282 return '(' . implode($glue, $parts) . ')';
283 }
284 }
285
286 if (3 !== count($node) || !isset($node['fn'], $node['cmp'], $node['val'])) {
287 return null;
288 }
289 if (!is_string($node['fn']) || !is_string($node['cmp']) || !isset($fns[$node['fn']], $cmps[$node['cmp']])) {
290 return null;
291 }
292
293 return 'date.' . $node['fn'] . '() ' . $node['cmp'] . ' ' . (int) $node['val'];
294 }
295
296 public static function toJs($json)
297 {
298 if (!is_string($json) || '' === $json) {
299 return '';
300 }
301
302 $decoded = json_decode($json, true);
303
304 if (JSON_ERROR_NONE !== json_last_error() || !is_array($decoded)) {
305 return '';
306 }
307
308 return static::nodeToJs($decoded);
309 }
310
311 public static function nodeToJs($node)
312 {
313 if (!is_array($node)) {
314 return wp_json_encode($node, static::JS_STRING_FLAGS);
315 }
316
317 if ([] === $node) {
318 return '{}';
319 }
320
321 if (isset($node['__ff_fp_incr']) && 1 === count($node)) {
322 $days = (int) $node['__ff_fp_incr'];
323 return $days ? "new Date().fp_incr({$days})" : 'new Date()';
324 }
325
326 if (isset($node['__ff_disable_days']) && 1 === count($node) && is_array($node['__ff_disable_days'])) {
327 $days = implode(', ', array_map('intval', $node['__ff_disable_days']));
328 return "function(date) { return [{$days}].indexOf(date.getDay()) !== -1; }";
329 }
330
331 if (isset($node['__ff_date']) && 1 === count($node)) {
332 $date = is_string($node['__ff_date']) ? $node['__ff_date'] : '';
333 if (!preg_match('/^[0-9A-Za-z ,:.\\/\\-]{1,40}$/', $date)) {
334 return 'null';
335 }
336 return 'new Date(' . wp_json_encode($date, static::JS_STRING_FLAGS) . ')';
337 }
338
339 if (isset($node['__ff_disable_expr']) && 1 === count($node)) {
340 $expr = static::exprToJs($node['__ff_disable_expr']);
341 return null === $expr ? 'null' : "function(date) { return {$expr}; }";
342 }
343
344 $isList = array_keys($node) === range(0, count($node) - 1);
345
346 $parts = [];
347 foreach ($node as $key => $value) {
348 $encoded = static::nodeToJs($value);
349 $parts[] = $isList ? $encoded : wp_json_encode((string) $key, static::JS_STRING_FLAGS) . ': ' . $encoded;
350 }
351
352 return $isList ? '[' . implode(', ', $parts) . ']' : '{' . implode(', ', $parts) . '}';
353 }
354 }
355