PluginProbe
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) / 2.0.1
Darkify – Dark Mode & Night Mode for Website & Admin (Dark Theme Included) v2.0.1
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.0.1, at src/Admin/Rest/AbstractRestController.php

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