PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.1.2
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.1.2
2.1.3 2.1.2 2.1.1 2.1.0 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 1.5.5 1.5.4 1.5.3 1.5.2 1.5.1 1.5.0 trunk 1.0.1 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 All 57 releases
darkify / src / Admin / Rest / AbstractRestController.php

AbstractRestController.php in Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) 2.1.2, at src/Admin/Rest/AbstractRestController.php

879 lines 37.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Base controller for the React admin SPA's REST endpoints.
5 *
6 * Holds the shared namespace, the capability gate, and the schema/sanitization
7 * helpers that read the SchemaRegistry (which the config classes in
8 * src/Admin/Views/*.php register into, unchanged) and normalize it for React.
9 * Concrete controllers (SettingsRest, LicenseRest) extend this and register
10 * their own routes.
11 *
12 * @package darkify
13 * @subpackage darkify/src/Admin/Rest
14 * @author ThemeAtelier<themeatelierbd@gmail.com>
15 */
16
17 namespace ThemeAtelier\Darkify\Admin\Rest;
18
19 use ThemeAtelier\Darkify\Admin\Schema\SchemaDefaults;
20 use ThemeAtelier\Darkify\Admin\Schema\SchemaRegistry;
21
22 if (! defined('ABSPATH')) {
23 die;
24 }
25
26 abstract class AbstractRestController
27 {
28 /**
29 * REST namespace for the admin SPA.
30 */
31 const NS = 'darkify/v1';
32
33 /**
34 * Option keys the settings API may read/write. Darkify keeps every setting in
35 * a single option, so this is a one-entry allowlist — but it is still an
36 * allowlist, so the `{key}` route parameter can never be pointed at an
37 * arbitrary option.
38 */
39 const SETTINGS_KEYS = [
40 'darkify',
41 ];
42
43 /**
44 * Config keys whose values React renders as raw HTML (the schema's own
45 * trusted markup — descriptions, tooltips, notices). They must NOT be
46 * entity-decoded into text.
47 */
48 const HTML_LABEL_KEYS = ['desc', 'help', 'title_help', 'content', 'before', 'after'];
49
50 public function __construct()
51 {
52 \add_action('rest_api_init', [$this, 'register_routes']);
53 }
54
55 /**
56 * Register this controller's routes. Called on `rest_api_init`.
57 */
58 abstract public function register_routes(): void;
59
60 /**
61 * Permission gate for every admin route.
62 *
63 * `manage_options` is the capability the old options screen was registered
64 * with, so this neither widens nor narrows who can change Darkify's settings.
65 */
66 public function can_manage(): bool
67 {
68 return \current_user_can('manage_options');
69 }
70
71 /**
72 * Whether an option key is one the settings API may touch.
73 */
74 protected function is_valid_settings_key(string $key): bool
75 {
76 return \in_array($key, self::SETTINGS_KEYS, true);
77 }
78
79 // ─── Schema helpers ─────────────────────────────────────────────────────────
80
81 /**
82 * The raw registered section arrays for an option key.
83 *
84 * Registration happens on `after_setup_theme` (Admin::init_components), which
85 * runs on every request including REST — so the registry is already populated
86 * by the time a route callback runs.
87 */
88 public function get_registered_sections(string $unique): array
89 {
90 $sections = SchemaRegistry::$sections[$unique] ?? [];
91 return \is_array($sections) ? $sections : [];
92 }
93
94 /**
95 * Normalize the registered sections into the tree React consumes.
96 *
97 * Section ids are `sanitize_title($title)` — the same slug the old framework
98 * used for its `#tab=` deep links, which is what lets an old bookmark like
99 * `?page=darkify#tab=license` map straight onto the new `#/license` route.
100 *
101 * @return array<int,array>
102 */
103 public function normalize_sections(array $sections): array
104 {
105 $tabs = [];
106
107 foreach ($sections as $section) {
108 $title = $section['title'] ?? '';
109 $tabs[] = [
110 'id' => $section['id'] ?? \sanitize_title($title !== '' ? $title : \uniqid('sec_')),
111 'title' => $title,
112 'icon' => $section['icon'] ?? '',
113 'fields' => $this->clean_fields($section['fields'] ?? []),
114 ];
115 }
116
117 return $this->decode_labels($tabs);
118 }
119
120 /**
121 * Recursively HTML-entity-decode plain-text label strings in a config tree,
122 * skipping the keys that hold trusted HTML markup. The configs come from PHP
123 * (a trusted source) and React escapes text nodes on render, so decoding here
124 * adds no XSS surface — it just stops `&amp;` showing up literally in a label.
125 *
126 * @param mixed $value The value to process.
127 * @param string $key The array key $value was found under.
128 * @return mixed
129 */
130 protected function decode_labels($value, string $key = '')
131 {
132 if (\is_array($value)) {
133 $out = [];
134 foreach ($value as $k => $v) {
135 $out[$k] = $this->decode_labels($v, (string) $k);
136 }
137 return $out;
138 }
139
140 if (\is_string($value) && ! \in_array($key, self::HTML_LABEL_KEYS, true)) {
141 return \html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8');
142 }
143
144 return $value;
145 }
146
147 /**
148 * Strip non-serializable bits from field configs (callbacks etc.) and keep the
149 * presentation/behavior keys React needs.
150 */
151 protected function clean_fields(array $fields): array
152 {
153 $keep = [
154 'id', 'type', 'title', 'subtitle', 'desc', 'help', 'title_help', 'default',
155 'options', 'placeholder', 'dependency', 'text_on', 'text_off', 'text_width',
156 'inline', 'content', 'style', 'attributes', 'unit', 'min', 'max', 'step',
157 'class', 'library', 'preview', 'multiple', 'fields', 'button_title',
158 'add_button', 'settings', 'before', 'after', 'prepend', 'append',
159 'all', 'units', 'max_items', 'min_items', 'columns', 'chosen', 'from_to',
160 'label', 'image', 'remove_title', 'preview_width', 'preview_height',
161 // `border` field toggles: which controls it offers. Without these the
162 // renderer can't tell that e.g. Switch Border wants a radius and a
163 // dark colour but no style dropdown, and falls back to a hardcoded
164 // width/style/colour trio. ('style' and 'all' are already kept above.)
165 'color', 'color2', 'color3', 'radius',
166 // `group`/`repeater` accordion row title (see build/.../fields/group/
167 // group.php): without these the React renderer has no way to know a
168 // row should be titled by e.g. its "select menu" sub-field, and falls
169 // back to whichever sub-field happens to have a value first.
170 'accordion_title_prefix', 'accordion_title_number', 'accordion_title_auto',
171 'accordion_title_by', 'accordion_title_by_prefix',
172 // `spacing` field per-side toggles (`'left' => false`). Without these the
173 // renderer cannot tell that e.g. "Margin From Top Right" only wants top and
174 // right, so it drew all four sides — two of them permanently blank inputs
175 // that saved a value the frontend then ignored.
176 'top', 'right', 'bottom', 'left',
177 // `accordion_title_require`: a sub-field id that must have a value before
178 // any composed title is shown, so a row stays "Switch #1" until the row is
179 // actually configured rather than showing an always-present sibling
180 // default alone. `row_title` is the singular label that numbered fallback
181 // uses, separate from the group's own (plural) `title`.
182 'accordion_title_require', 'row_title',
183 // `heading` field flag marking the start of one of a merged page's former
184 // tabs (e.g. Advanced's HTML/CSS Restriction / Custom CSS). Without it
185 // FieldRenderer can't tell those headings apart from an ordinary
186 // sub-heading, and never splits the page into separate cards with a real
187 // gap between them.
188 'section_start',
189 // `repeater`/`group` empty-state copy. Without these the renderer falls
190 // back to a generic one-line "No items yet." for every repeater alike.
191 'empty_title', 'empty_desc',
192 // `text` field opt-in validation mode (e.g. 'css_selectors' for
193 // Alternative Switch Selectors). Without it the renderer can't tell a
194 // plain text field wants live selector validation with an inline error,
195 // and falls back to an unvalidated input.
196 'validate',
197 // Field opt-in: render this row on its own tinted, inset sub-card
198 // (e.g. dependent "Level" sliders under Image/Video Controls).
199 'panel',
200 // `switcher` opt-in: commit instantly (auto-save) even though other
201 // fields depend on it. See the eligibility check in FieldRenderer.
202 'auto_save',
203 ];
204
205 $out = [];
206 foreach ($fields as $field) {
207 if (! \is_array($field)) {
208 continue;
209 }
210
211 $clean = [];
212 foreach ($keep as $k) {
213 if (\array_key_exists($k, $field)) {
214 $clean[$k] = $field[$k];
215 }
216 }
217
218 // Resolve dynamic option sources ('pages', 'posts', …) into a real
219 // {value => label} map so React can render the choices.
220 if (isset($clean['options']) && \is_string($clean['options'])) {
221 $clean['options'] = $this->resolve_options($field);
222 }
223
224 // Serialize `options` as an ordered list of [key, label] pairs
225 // rather than a plain {key: label} map. A PHP associative array
226 // with a canonical integer-looking key (e.g. Switch Size's "1"
227 // for "M", alongside "0.6" and "custom") round-trips through JSON
228 // as a plain JS object — and JS hoists integer-index-like keys to
229 // the front of a plain object's own-property order ahead of
230 // non-numeric ones, REGARDLESS of insertion order. That silently
231 // reordered "M" in front of "XS"/"S", and would do the same to
232 // any numeric-keyed dropdown (pages/posts by ID, sizes, …). A
233 // list of pairs has no such ambiguity: order is exactly what PHP
234 // declared (see FieldRenderer.jsx's `optionEntries`, which reads
235 // this shape).
236 if (isset($clean['options']) && \is_array($clean['options'])) {
237 $clean['options'] = \array_map(
238 function ($k, $v) {
239 return [$k, $v];
240 },
241 \array_keys($clean['options']),
242 \array_values($clean['options'])
243 );
244 }
245
246 // The two shortcode fields are read-only helpers: the old framework
247 // hard-coded their template into the field renderer. Now that the
248 // renderer is gone, PHP still owns the string — it travels in the
249 // schema instead of being duplicated in JS.
250 if (isset($clean['type']) && isset(self::SHORTCODE_TEMPLATES[$clean['type']])) {
251 $clean['shortcode'] = self::SHORTCODE_TEMPLATES[$clean['type']];
252 }
253
254 // Recurse into nested fields (fieldset / repeater / group).
255 if (! empty($field['fields']) && \is_array($field['fields'])) {
256 $clean['fields'] = $this->clean_fields($field['fields']);
257 }
258
259 // Recurse into section_tab sub-tabs. Each tab is a {title, icon,
260 // fields} group whose fields keep their own top-level ids, so they
261 // still save flat into the option.
262 if (! empty($field['tabs']) && \is_array($field['tabs'])) {
263 $clean['tabs'] = \array_map(function ($tab) {
264 $title = $tab['title'] ?? '';
265 return [
266 'id' => $tab['id'] ?? \sanitize_title($title !== '' ? $title : \uniqid('tab_')),
267 'title' => $title,
268 'icon' => $tab['icon'] ?? '',
269 'fields' => $this->clean_fields($tab['fields'] ?? []),
270 // Lets a whole tab hide conditionally, not just individual
271 // fields within it.
272 'dependency' => $tab['dependency'] ?? null,
273 ];
274 }, \array_values($field['tabs']));
275 }
276
277 $out[] = $clean;
278 }
279
280 return $out;
281 }
282
283 /**
284 * The read-only shortcode templates for the `switcher_shortcode` /
285 * `switcher_shortcode_v2` fields, byte-for-byte as the old framework's field
286 * renderers printed them.
287 */
288 const SHORTCODE_TEMPLATES = [
289 'switcher_shortcode' => '[darkify switch="1" light_mode_bg="#121116" dark_mode_bg="#ffffff" light_mode_color="#ffffff" dark_mode_color="#121116" border="0px" border_light_color="#121119" border_dark_color="#ffffff" border_radius="7px" switch_size="100"]',
290 'switcher_shortcode_v2' => '[darkify switch="5" light_mode_bg="#121116" dark_mode_bg="#ffffff" light_mode_color="#ffffff" dark_mode_color="#121116" border="0px" border_light_color="#121119" border_dark_color="#ffffff" border_radius="30px"]',
291 ];
292
293 /**
294 * Resolve a dynamic-options token (a string like 'pages') into an id => label
295 * map. An unknown token resolves to an empty list rather than leaking the
296 * token itself into the UI as a bogus choice.
297 *
298 * @param array $field The raw field config (may carry `query_args`).
299 * @return array<string,string>
300 */
301 protected function resolve_options(array $field): array
302 {
303 $token = $field['options'];
304 $out = [];
305
306 switch ($token) {
307 case 'posts': {
308 $args = (isset($field['query_args']) && \is_array($field['query_args']))
309 ? $field['query_args']
310 : ['post_type' => 'post'];
311 $args['posts_per_page'] = $args['posts_per_page'] ?? -1;
312 $args['post_status'] = $args['post_status'] ?? 'publish';
313 foreach (\get_posts($args) as $post) {
314 $out[(string) $post->ID] = ($post->post_title !== '')
315 ? $post->post_title
316 /* translators: %d: post ID. */
317 : \sprintf(\__('(no title) #%d', 'darkify'), $post->ID);
318 }
319 break;
320 }
321
322 case 'pages': {
323 // The theme-rendered screens come first — they have no Page post
324 // behind them, so get_pages() can never surface them.
325 $out = $this->virtual_page_options();
326
327 // get_pages() returns false (not an array) if it rejects its
328 // arguments, which would otherwise warn on the foreach.
329 $pages = \get_pages(['post_status' => 'publish']);
330 foreach (\is_array($pages) ? $pages : [] as $page) {
331 $out[(string) $page->ID] = ($page->post_title !== '')
332 ? $page->post_title
333 /* translators: %d: page ID. */
334 : \sprintf(\__('(no title) #%d', 'darkify'), $page->ID);
335 }
336 break;
337 }
338
339 case 'post_type': {
340 foreach (\get_post_types(['public' => true], 'objects') as $slug => $obj) {
341 $out[(string) $slug] = $obj->labels->singular_name ?? $slug;
342 }
343 break;
344 }
345
346 // WordPress menus are `nav_menu` taxonomy terms, keyed by term_id —
347 // the same id the old framework's WP_Term_Query-based resolver used
348 // (see build/.../fields.class.php), so an existing saved
349 // `switch_in_menu_location` term_id still resolves to the right menu.
350 case 'menu':
351 case 'menus': {
352 foreach (\wp_get_nav_menus() as $menu) {
353 $out[(string) $menu->term_id] = $menu->name;
354 }
355 break;
356 }
357 }
358
359 return $out;
360 }
361
362 /**
363 * The theme-rendered screens offered alongside real pages by the `pages`
364 * token, keyed by the id the frontend matches on.
365 *
366 * These screens are produced by the theme and have no Page post behind them,
367 * so `get_pages()` cannot return them and they were unselectable — even
368 * though DarkifyUtils::isRestrictedByAllowedPages() and
369 * ::isRestrictedByDisallowedPages() already test for exactly these ids.
370 *
371 * The KEYS ARE A CONTRACT with those two methods and must not change. Note
372 * `'0'`: on a static front page the frontend deliberately matches "0"
373 * instead of the assigned page's real ID, so selecting that page in the list
374 * does NOT cover the front page — this entry is the only way to reach it.
375 * None of the keys can collide with a page ID (0 is never a post ID, and the
376 * rest are non-numeric).
377 *
378 * @return array<string,string>
379 */
380 protected function virtual_page_options(): array
381 {
382 return [
383 '0' => \__('Front Page', 'darkify'),
384 'post_page' => \__('Blog Page (Posts Index)', 'darkify'),
385 'post_archive' => \__('Category & Tag Archives', 'darkify'),
386 'search_search' => \__('Search Results', 'darkify'),
387 '404_page' => \__('404 Page', 'darkify'),
388 'lr' => \__('Login & Register', 'darkify'),
389 ];
390 }
391
392 /**
393 * Build the id => default map for a section list, preserving each field's
394 * NESTED storage shape so a fresh-install value map matches exactly what the
395 * frontend templates and the React form read.
396 *
397 * The subtlety this fixes: a `fieldset` stores its children under its OWN id
398 * as a nested object — e.g. `switcher_button_position` holds
399 * `['dark_mode_switch_position' => 'bottom_right', 'switch_position_top_right'
400 * => ['top' => '40', 'right' => '40'], …]`, and the frontend reads them as
401 * `$options['switcher_button_position']['switch_position_top_right']['top']`
402 * (see Frontend/templates/views/switch.php). Flattening those child defaults
403 * to the top level (`$defaults['switch_position_top_right']`) — the old
404 * behaviour — left the nested object the UI actually reads absent, so on a
405 * new install every fieldset control (Positioning, Tooltip, Hide-on-Mobile,
406 * the image/video filters, Different-Switch-in-Mobile…) rendered empty. Here
407 * a fieldset's default is BUILT as that nested object instead.
408 *
409 * `group`/`repeater` stay leaves: they store a LIST of rows and carry their
410 * own `default`; their sub-fields are a per-row template applied when a row
411 * is added, not top-level or nested-object defaults. `section_tab` sub-tabs
412 * save their fields flat, so those recurse to the top level unchanged.
413 */
414 public function collect_defaults(array $sections): array
415 {
416 // The walk itself lives in SchemaDefaults so the install seeder
417 // (Admin::seed_default_options) resolves defaults through exactly the
418 // same code path this controller does.
419 return SchemaDefaults::collect($sections);
420 }
421
422 /**
423 * Merge saved values over schema defaults.
424 *
425 * Unlike a plain `array_merge($defaults, $saved)`, an empty-string saved value
426 * does NOT clobber a non-empty default. In this data model there is no null —
427 * an empty string is the "never meaningfully set" marker that a partial or
428 * legacy save leaves behind for fields the old form never submitted (a
429 * switcher or button_set hidden behind a dependency, say). Letting `''` win
430 * would silently blank out documented defaults for those users.
431 *
432 * Real choices are preserved: `'0'`/`false` (a switcher turned off), a
433 * selected option string, an explicit empty array (a deliberately cleared
434 * repeater) and any non-empty string all still override the default. Only the
435 * ambiguous empty string yields.
436 *
437 * Nested fieldset objects are merged RECURSIVELY: a saved
438 * `switcher_button_position` that only carries the sub-keys the form actually
439 * submitted (an older save, or a sub-field hidden behind a dependency) still
440 * resolves its missing sub-keys to their documented defaults, while every
441 * saved sub-value wins. This makes the form resolve nested defaults the same
442 * way the frontend already does (each nested read there has its own
443 * fallback). The recursion is gated on BOTH sides being associative
444 * (string-keyed) arrays, so repeater/group LISTS and scalars keep the plain
445 * replace — a deliberately emptied or reordered list is honoured exactly as
446 * before.
447 */
448 public function merge_defaults(array $defaults, array $saved): array
449 {
450 $values = $defaults;
451 foreach ($saved as $key => $value) {
452 if (
453 $value === ''
454 && \array_key_exists($key, $defaults)
455 && $defaults[$key] !== ''
456 && $defaults[$key] !== null
457 ) {
458 continue; // keep the non-empty default
459 }
460 if (
461 \is_array($value)
462 && isset($defaults[$key])
463 && \is_array($defaults[$key])
464 && $this->is_assoc_array($defaults[$key])
465 && $this->is_assoc_array($value)
466 ) {
467 $values[$key] = $this->merge_defaults($defaults[$key], $value);
468 continue;
469 }
470 $values[$key] = $value;
471 }
472 return $values;
473 }
474
475 /**
476 * Whether an array is associative (string-keyed) rather than a plain 0..n
477 * list. Used to tell a fieldset's nested object (deep-merge) apart from a
478 * repeater/group's list of rows (plain replace). An empty array is treated
479 * as a list, so an intentionally cleared value replaces rather than merges.
480 */
481 protected function is_assoc_array(array $arr): bool
482 {
483 if ($arr === []) {
484 return false;
485 }
486 return \array_keys($arr) !== \range(0, \count($arr) - 1);
487 }
488
489 /**
490 * Flatten id => type across a section list.
491 */
492 public function collect_field_types(array $sections): array
493 {
494 $types = [];
495 $this->walk_fields($sections, function ($field) use (&$types) {
496 if (! empty($field['id']) && ! empty($field['type'])) {
497 $types[$field['id']] = $field['type'];
498 }
499 });
500 return $types;
501 }
502
503 /**
504 * Recursively visit every field across sections (including nested `fields`
505 * and `section_tab` tabs).
506 */
507 protected function walk_fields(array $sections, callable $cb): void
508 {
509 foreach ($sections as $section) {
510 if (empty($section['fields']) || ! \is_array($section['fields'])) {
511 continue;
512 }
513 foreach ($section['fields'] as $field) {
514 if (! \is_array($field)) {
515 continue;
516 }
517 $cb($field);
518 if (! empty($field['fields']) && \is_array($field['fields'])) {
519 $this->walk_fields([['fields' => $field['fields']]], $cb);
520 }
521 if (! empty($field['tabs']) && \is_array($field['tabs'])) {
522 foreach ($field['tabs'] as $tab) {
523 if (! empty($tab['fields']) && \is_array($tab['fields'])) {
524 $this->walk_fields([['fields' => $tab['fields']]], $cb);
525 }
526 }
527 }
528 }
529 }
530 }
531
532 // ─── Sanitization ───────────────────────────────────────────────────────────
533
534 /**
535 * Type-aware sanitization of an incoming values map.
536 *
537 * `$type_map` is the FLAT id => type map produced by collect_field_types(),
538 * which walks nested fields too — so a field's type is found by id no matter
539 * how deeply it is nested. That matters: 26 of Darkify's value-bearing fields
540 * live inside a `fieldset` or a `repeater` row (the brightness/grayscale
541 * toggles and sliders, the image/video exclusion lists, the replacement
542 * uploads…). Sanitizing those by position rather than by type would coerce
543 * `true` to `'1'`, `100` to `'100'`, and — worst — strip the newlines out of
544 * the multi-line exclusion lists.
545 */
546 public function sanitize_values(array $values, array $type_map): array
547 {
548 $clean = [];
549 foreach ($values as $key => $value) {
550 $clean[$key] = $this->sanitize_value($value, $type_map[$key] ?? '', $type_map);
551 }
552 return $clean;
553 }
554
555 /**
556 * Sanitize a single value by field type.
557 *
558 * Note the deliberate holes: `code_editor` and `textarea` carry the user's own
559 * CSS and their newline-separated selector lists (Dark Mode CSS, Normal Mode
560 * CSS, the element allow/deny lists). Running those through
561 * `sanitize_text_field()` would collapse newlines and mangle the rules, so they
562 * get the type-appropriate treatment instead.
563 *
564 * @param mixed $value The value to sanitize.
565 * @param string $type The field type, if known.
566 * @param array $type_map Flat id => type map, used to keep nested values
567 * (fieldset children, repeater rows) type-aware.
568 */
569 protected function sanitize_value($value, string $type = '', array $type_map = [])
570 {
571 // Preserved as-is rather than stringified to "": a cleared media field
572 // sends null, and casting that to a string would put "" into the option
573 // where the frontend expects either a URL or nothing.
574 if ($value === null) {
575 return null;
576 }
577
578 if (\is_array($value)) {
579 $out = [];
580 foreach ($value as $k => $v) {
581 // A repeater's rows are a numeric list, so `$k` is an index and
582 // resolves to no type — recursion then reaches the row object,
583 // whose keys ARE field ids and do resolve. A fieldset's keys are
584 // field ids directly. Either way the leaf is typed correctly.
585 $key = \sanitize_text_field((string) $k);
586 $out[$key] = $this->sanitize_value($v, $type_map[$key] ?? '', $type_map);
587 }
588 return $out;
589 }
590
591 switch ($type) {
592 case 'textarea':
593 return \sanitize_textarea_field((string) $value);
594
595 case 'code_editor':
596 // Raw CSS/JS the user authored. `wp_kses_post` would eat `>` in a
597 // child selector (`.a > .b`), so the value is stored verbatim —
598 // it is written only by `manage_options` users and is never
599 // executed as PHP.
600 return (string) $value;
601
602 case 'switcher':
603 case 'checkbox':
604 return $value; // booleans / arrays already handled above
605
606 case 'number':
607 case 'slider':
608 case 'spinner':
609 return \is_numeric($value) ? $value + 0 : \sanitize_text_field((string) $value);
610
611 case 'upload':
612 return \esc_url_raw((string) $value);
613
614 default:
615 return \sanitize_text_field((string) $value);
616 }
617 }
618
619 // ─── Pro-feature locking (free plugin) ──────────────────────────────────────
620
621 /**
622 * Whether a field config is Pro-locked in the free plugin.
623 *
624 * The free config files have always marked Pro-only fields with a CSS class
625 * (`only_pro`, `switcher_pro_only`, `repeater_pro_only`) — the retired options
626 * framework rendered those rows dimmed with an upgrade overlay. The class
627 * markers remain the single source of truth, so the set of locked fields is
628 * exactly the set the old admin locked; here they are translated into the
629 * `pro: true` flag the React admin's ProLock UI consumes (same contract as
630 * Chat Help).
631 *
632 * The marker convention is a class token that is `only_pro` or ends in
633 * `_pro_only` (`switcher_pro_only`, `repeater_pro_only`, …). Matching the
634 * whole family — rather than an explicit list — is what fixes the Replace
635 * Images / Replace Videos repeaters: their `repeater_pro_only` marker was
636 * silently ignored after the React migration (an earlier, narrower pattern
637 * only matched a bare `pro_only` token), so those Pro-only repeaters wrongly
638 * rendered as editable free fields even though the free frontend never had
639 * any replacement logic to honour them.
640 */
641 protected function is_pro_field(array $field): bool
642 {
643 $class = isset($field['class']) && \is_string($field['class']) ? $field['class'] : '';
644 return (bool) \preg_match('/(?:^|\s)(?:only_pro|[A-Za-z0-9_-]*pro_only)(?:\s|$)/', $class);
645 }
646
647 /**
648 * Option keys inside a field's `options` map that are Pro-locked — the
649 * config marks them with `'pro_only' => true` on the option row (e.g. the
650 * Pro switch styles in SwitcherStyle.php).
651 *
652 * @return array<int,string>
653 */
654 protected function locked_option_keys(array $field): array
655 {
656 $locked = [];
657 if (! empty($field['options']) && \is_array($field['options'])) {
658 foreach ($field['options'] as $key => $opt) {
659 if (\is_array($opt) && ! empty($opt['pro_only'])) {
660 $locked[] = (string) $key;
661 }
662 }
663 }
664 return $locked;
665 }
666
667 /**
668 * Stamp `pro`/`pro_options` flags onto a normalized schema tree so the React
669 * admin can render Pro-only fields and choices as locked previews — the same
670 * flags Chat Help's free admin uses (see chat-help-react's ProLock).
671 */
672 public function apply_pro_flags(array $tree): array
673 {
674 foreach ($tree as &$section) {
675 if (! empty($section['fields']) && \is_array($section['fields'])) {
676 $section['fields'] = $this->mark_pro_fields($section['fields']);
677 }
678 }
679 unset($section);
680 return $tree;
681 }
682
683 /**
684 * Recursively add `pro`/`pro_options` flags to a field list (including
685 * nested fieldset/group fields and section_tab tabs).
686 *
687 * @param array $fields Normalized field list.
688 * @param bool $force Inherit a Pro lock from an enclosing field.
689 */
690 protected function mark_pro_fields(array $fields, bool $force = false): array
691 {
692 foreach ($fields as &$field) {
693 if (! \is_array($field)) {
694 continue;
695 }
696
697 $field_pro = $force || $this->is_pro_field($field);
698 if ($field_pro) {
699 $field['pro'] = true;
700 }
701
702 $locked = $this->locked_option_keys($field);
703 if (! empty($locked)) {
704 $field['pro_options'] = $locked;
705 }
706
707 if (! empty($field['fields']) && \is_array($field['fields'])) {
708 $field['fields'] = $this->mark_pro_fields($field['fields'], $field_pro);
709 }
710 if (! empty($field['tabs']) && \is_array($field['tabs'])) {
711 foreach ($field['tabs'] as &$tab) {
712 if (! empty($tab['fields']) && \is_array($tab['fields'])) {
713 $tab['fields'] = $this->mark_pro_fields($tab['fields'], $force);
714 }
715 }
716 unset($tab);
717 }
718 }
719 unset($field);
720 return $fields;
721 }
722
723 /**
724 * Drop Pro-locked keys from an incoming values map before persisting, so a
725 * crafted request can never flip a Pro-only setting in the free plugin. The
726 * locked UI never submits these (its onChange is neutered) — this is
727 * defence-in-depth, mirroring Chat Help's strip_pro_keys().
728 *
729 * Locked fields are addressed by PATH, not just by top-level id: a fieldset
730 * stores its children NESTED under its own id (see collect_defaults), so a
731 * locked child inside an unlocked fieldset must be stripped inside that
732 * nested object. Dropping the key (rather than blanking it) means the
733 * caller's array_merge keeps whatever value is already stored.
734 *
735 * @param array $values Sanitized incoming values.
736 * @param array $sections The registered sections for the option key.
737 */
738 public function strip_pro_keys(array $values, array $sections): array
739 {
740 $tree = $this->apply_pro_flags($this->normalize_sections($sections));
741 $pro_paths = [];
742 $locked_opts = [];
743 foreach ($tree as $section) {
744 if (! empty($section['fields']) && \is_array($section['fields'])) {
745 $this->collect_pro_paths($section['fields'], [], $pro_paths, $locked_opts);
746 }
747 }
748
749 foreach ($pro_paths as $path) {
750 $this->unset_path($values, $path);
751 }
752
753 foreach ($locked_opts as $entry) {
754 $current = $this->get_path($values, $entry['path'], $exists);
755 if (! $exists) {
756 continue;
757 }
758 if (\is_array($current)) {
759 // Multi-value field: drop only the Pro-locked choices, keep the
760 // free ones.
761 $filtered = \array_values(\array_filter(
762 $current,
763 static function ($v) use ($entry) {
764 return ! \in_array((string) $v, $entry['options'], true);
765 }
766 ));
767 $this->set_path($values, $entry['path'], $filtered);
768 continue;
769 }
770 if (\is_scalar($current) && \in_array((string) $current, $entry['options'], true)) {
771 $this->unset_path($values, $entry['path']);
772 }
773 }
774
775 return $values;
776 }
777
778 /**
779 * Recursively gather the storage paths of Pro-locked fields and of fields
780 * with Pro-locked option choices, from a flagged schema tree.
781 *
782 * A `fieldset` pushes its own id onto the path (its children save nested
783 * under it); `section_tab` tabs save flat, so the path passes through.
784 * `group`/`repeater` sub-fields never save as flat keys — their values nest
785 * per-row inside the parent's own list, and a locked parent is stripped by
786 * its own path — so their sub-fields are not descended into (same rationale
787 * as Chat Help's collect_pro_ids).
788 *
789 * @param array $fields Flagged field list.
790 * @param array $path Ids of the enclosing fieldsets.
791 * @param array $pro_paths Out: paths of fully-locked fields.
792 * @param array $locked_opts Out: list of ['path' => …, 'options' => …].
793 */
794 protected function collect_pro_paths(array $fields, array $path, array &$pro_paths, array &$locked_opts): void
795 {
796 foreach ($fields as $field) {
797 if (! \is_array($field)) {
798 continue;
799 }
800 $id = (string) ($field['id'] ?? '');
801 $type = (string) ($field['type'] ?? '');
802
803 if ($id !== '' && ! empty($field['pro'])) {
804 $pro_paths[] = \array_merge($path, [$id]);
805 }
806 if ($id !== '' && ! empty($field['pro_options']) && \is_array($field['pro_options'])) {
807 $locked_opts[] = [
808 'path' => \array_merge($path, [$id]),
809 'options' => \array_map('strval', $field['pro_options']),
810 ];
811 }
812
813 if (! empty($field['tabs']) && \is_array($field['tabs'])) {
814 foreach ($field['tabs'] as $tab) {
815 if (! empty($tab['fields']) && \is_array($tab['fields'])) {
816 $this->collect_pro_paths($tab['fields'], $path, $pro_paths, $locked_opts);
817 }
818 }
819 }
820
821 if (! empty($field['fields']) && \is_array($field['fields'])) {
822 if ($type === 'fieldset' && $id !== '') {
823 $this->collect_pro_paths($field['fields'], \array_merge($path, [$id]), $pro_paths, $locked_opts);
824 } elseif (! \in_array($type, ['group', 'repeater'], true)) {
825 $this->collect_pro_paths($field['fields'], $path, $pro_paths, $locked_opts);
826 }
827 }
828 }
829 }
830
831 /** Unset a nested key addressed by a path of ids. */
832 protected function unset_path(array &$values, array $path): void
833 {
834 $last = \array_pop($path);
835 $ref = &$values;
836 foreach ($path as $step) {
837 if (! isset($ref[$step]) || ! \is_array($ref[$step])) {
838 return;
839 }
840 $ref = &$ref[$step];
841 }
842 unset($ref[$last]);
843 }
844
845 /**
846 * Read a nested value addressed by a path of ids.
847 *
848 * @param bool $exists Out: whether the full path resolved.
849 * @return mixed
850 */
851 protected function get_path(array $values, array $path, &$exists)
852 {
853 $exists = false;
854 $cur = $values;
855 foreach ($path as $step) {
856 if (! \is_array($cur) || ! \array_key_exists($step, $cur)) {
857 return null;
858 }
859 $cur = $cur[$step];
860 }
861 $exists = true;
862 return $cur;
863 }
864
865 /** Write a nested value addressed by a path of ids (path must resolve). */
866 protected function set_path(array &$values, array $path, $value): void
867 {
868 $last = \array_pop($path);
869 $ref = &$values;
870 foreach ($path as $step) {
871 if (! isset($ref[$step]) || ! \is_array($ref[$step])) {
872 return;
873 }
874 $ref = &$ref[$step];
875 }
876 $ref[$last] = $value;
877 }
878 }
879