| 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 `&` 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 |
* Navigation sources that are stored as posts rather than `nav_menu` terms. |
| 285 |
* |
| 286 |
* Keyed by post type, valued by the short source label shown after the menu |
| 287 |
* name in the picker. `kadence_navigation` is only registered when Kadence |
| 288 |
* Blocks is active, which is why every caller checks post_type_exists() |
| 289 |
* before querying — the list itself is deliberately static so it costs |
| 290 |
* nothing to ask for. |
| 291 |
*/ |
| 292 |
protected static function darkify_block_menu_sources() |
| 293 |
{ |
| 294 |
return [ |
| 295 |
'wp_navigation' => \__('Block menu', 'darkify'), |
| 296 |
'kadence_navigation' => \__('Kadence', 'darkify'), |
| 297 |
]; |
| 298 |
} |
| 299 |
|
| 300 |
/** |
| 301 |
* The read-only shortcode templates for the `switcher_shortcode` / |
| 302 |
* `switcher_shortcode_v2` fields, byte-for-byte as the old framework's field |
| 303 |
* renderers printed them. |
| 304 |
*/ |
| 305 |
const SHORTCODE_TEMPLATES = [ |
| 306 |
'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"]', |
| 307 |
'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"]', |
| 308 |
]; |
| 309 |
|
| 310 |
/** |
| 311 |
* Resolve a dynamic-options token (a string like 'pages') into an id => label |
| 312 |
* map. An unknown token resolves to an empty list rather than leaking the |
| 313 |
* token itself into the UI as a bogus choice. |
| 314 |
* |
| 315 |
* @param array $field The raw field config (may carry `query_args`). |
| 316 |
* @return array<string,string> |
| 317 |
*/ |
| 318 |
protected function resolve_options(array $field): array |
| 319 |
{ |
| 320 |
$token = $field['options']; |
| 321 |
$out = []; |
| 322 |
|
| 323 |
switch ($token) { |
| 324 |
case 'posts': { |
| 325 |
$args = (isset($field['query_args']) && \is_array($field['query_args'])) |
| 326 |
? $field['query_args'] |
| 327 |
: ['post_type' => 'post']; |
| 328 |
$args['posts_per_page'] = $args['posts_per_page'] ?? -1; |
| 329 |
$args['post_status'] = $args['post_status'] ?? 'publish'; |
| 330 |
foreach (\get_posts($args) as $post) { |
| 331 |
$out[(string) $post->ID] = ($post->post_title !== '') |
| 332 |
? $post->post_title |
| 333 |
/* translators: %d: post ID. */ |
| 334 |
: \sprintf(\__('(no title) #%d', 'darkify'), $post->ID); |
| 335 |
} |
| 336 |
break; |
| 337 |
} |
| 338 |
|
| 339 |
case 'pages': { |
| 340 |
// The theme-rendered screens come first — they have no Page post |
| 341 |
// behind them, so get_pages() can never surface them. |
| 342 |
$out = $this->virtual_page_options(); |
| 343 |
|
| 344 |
// get_pages() returns false (not an array) if it rejects its |
| 345 |
// arguments, which would otherwise warn on the foreach. |
| 346 |
$pages = \get_pages(['post_status' => 'publish']); |
| 347 |
foreach (\is_array($pages) ? $pages : [] as $page) { |
| 348 |
$out[(string) $page->ID] = ($page->post_title !== '') |
| 349 |
? $page->post_title |
| 350 |
/* translators: %d: page ID. */ |
| 351 |
: \sprintf(\__('(no title) #%d', 'darkify'), $page->ID); |
| 352 |
} |
| 353 |
break; |
| 354 |
} |
| 355 |
|
| 356 |
case 'post_type': { |
| 357 |
foreach (\get_post_types(['public' => true], 'objects') as $slug => $obj) { |
| 358 |
$out[(string) $slug] = $obj->labels->singular_name ?? $slug; |
| 359 |
} |
| 360 |
break; |
| 361 |
} |
| 362 |
|
| 363 |
// WordPress menus are `nav_menu` taxonomy terms, keyed by term_id — |
| 364 |
// the same id the old framework's WP_Term_Query-based resolver used |
| 365 |
// (see build/.../fields.class.php), so an existing saved |
| 366 |
// `switch_in_menu_location` term_id still resolves to the right menu. |
| 367 |
case 'menu': |
| 368 |
case 'menus': { |
| 369 |
foreach (\wp_get_nav_menus() as $menu) { |
| 370 |
$out[(string) $menu->term_id] = $menu->name; |
| 371 |
} |
| 372 |
|
| 373 |
/* |
| 374 |
* Block-theme and Kadence navigations are POSTS, not `nav_menu` |
| 375 |
* terms, so wp_get_nav_menus() above cannot see them. On a block |
| 376 |
* theme — or any site whose menus were built with Kadence |
| 377 |
* Navigation — this field came back completely empty even though |
| 378 |
* the site had menus, because there were no classic menus left to |
| 379 |
* list. |
| 380 |
* |
| 381 |
* Their ids are POST ids and would collide with the term ids |
| 382 |
* above (post 5 and term 5 are different menus), so they are |
| 383 |
* namespaced `<post_type>:<id>`. Bare numeric keys stay reserved |
| 384 |
* for classic menus, which is what keeps every already-saved |
| 385 |
* `switch_in_menu_location` resolving to exactly the menu it |
| 386 |
* always did. |
| 387 |
*/ |
| 388 |
foreach (self::darkify_block_menu_sources() as $post_type => $label) { |
| 389 |
if (! \post_type_exists($post_type)) { |
| 390 |
continue; |
| 391 |
} |
| 392 |
|
| 393 |
$navs = \get_posts([ |
| 394 |
'post_type' => $post_type, |
| 395 |
'post_status' => 'publish', |
| 396 |
'numberposts' => -1, |
| 397 |
'orderby' => 'title', |
| 398 |
'order' => 'ASC', |
| 399 |
'suppress_filters' => false, |
| 400 |
]); |
| 401 |
|
| 402 |
foreach (\is_array($navs) ? $navs : [] as $nav) { |
| 403 |
$title = ($nav->post_title !== '') |
| 404 |
? $nav->post_title |
| 405 |
/* translators: %d: navigation menu post ID. */ |
| 406 |
: \sprintf(\__('(no title) #%d', 'darkify'), $nav->ID); |
| 407 |
|
| 408 |
// The source is shown because a site can legitimately |
| 409 |
// have a classic menu, a block menu and a Kadence menu |
| 410 |
// all called "Primary Menu". |
| 411 |
$out[$post_type . ':' . $nav->ID] = $title . ' — ' . $label; |
| 412 |
} |
| 413 |
} |
| 414 |
break; |
| 415 |
} |
| 416 |
} |
| 417 |
|
| 418 |
return $out; |
| 419 |
} |
| 420 |
|
| 421 |
/** |
| 422 |
* The theme-rendered screens offered alongside real pages by the `pages` |
| 423 |
* token, keyed by the id the frontend matches on. |
| 424 |
* |
| 425 |
* These screens are produced by the theme and have no Page post behind them, |
| 426 |
* so `get_pages()` cannot return them and they were unselectable — even |
| 427 |
* though DarkifyUtils::isRestrictedByAllowedPages() and |
| 428 |
* ::isRestrictedByDisallowedPages() already test for exactly these ids. |
| 429 |
* |
| 430 |
* The KEYS ARE A CONTRACT with those two methods and must not change. Note |
| 431 |
* `'0'`: on a static front page the frontend deliberately matches "0" |
| 432 |
* instead of the assigned page's real ID, so selecting that page in the list |
| 433 |
* does NOT cover the front page — this entry is the only way to reach it. |
| 434 |
* None of the keys can collide with a page ID (0 is never a post ID, and the |
| 435 |
* rest are non-numeric). |
| 436 |
* |
| 437 |
* @return array<string,string> |
| 438 |
*/ |
| 439 |
protected function virtual_page_options(): array |
| 440 |
{ |
| 441 |
return [ |
| 442 |
'0' => \__('Front Page', 'darkify'), |
| 443 |
'post_page' => \__('Blog Page (Posts Index)', 'darkify'), |
| 444 |
'post_archive' => \__('Category & Tag Archives', 'darkify'), |
| 445 |
'search_search' => \__('Search Results', 'darkify'), |
| 446 |
'404_page' => \__('404 Page', 'darkify'), |
| 447 |
'lr' => \__('Login & Register', 'darkify'), |
| 448 |
]; |
| 449 |
} |
| 450 |
|
| 451 |
/** |
| 452 |
* Build the id => default map for a section list, preserving each field's |
| 453 |
* NESTED storage shape so a fresh-install value map matches exactly what the |
| 454 |
* frontend templates and the React form read. |
| 455 |
* |
| 456 |
* The subtlety this fixes: a `fieldset` stores its children under its OWN id |
| 457 |
* as a nested object — e.g. `switcher_button_position` holds |
| 458 |
* `['dark_mode_switch_position' => 'bottom_right', 'switch_position_top_right' |
| 459 |
* => ['top' => '40', 'right' => '40'], …]`, and the frontend reads them as |
| 460 |
* `$options['switcher_button_position']['switch_position_top_right']['top']` |
| 461 |
* (see Frontend/templates/views/switch.php). Flattening those child defaults |
| 462 |
* to the top level (`$defaults['switch_position_top_right']`) — the old |
| 463 |
* behaviour — left the nested object the UI actually reads absent, so on a |
| 464 |
* new install every fieldset control (Positioning, Tooltip, Hide-on-Mobile, |
| 465 |
* the image/video filters, Different-Switch-in-Mobile…) rendered empty. Here |
| 466 |
* a fieldset's default is BUILT as that nested object instead. |
| 467 |
* |
| 468 |
* `group`/`repeater` stay leaves: they store a LIST of rows and carry their |
| 469 |
* own `default`; their sub-fields are a per-row template applied when a row |
| 470 |
* is added, not top-level or nested-object defaults. `section_tab` sub-tabs |
| 471 |
* save their fields flat, so those recurse to the top level unchanged. |
| 472 |
*/ |
| 473 |
public function collect_defaults(array $sections): array |
| 474 |
{ |
| 475 |
// The walk itself lives in SchemaDefaults so the install seeder |
| 476 |
// (Admin::seed_default_options) resolves defaults through exactly the |
| 477 |
// same code path this controller does. |
| 478 |
return SchemaDefaults::collect($sections); |
| 479 |
} |
| 480 |
|
| 481 |
/** |
| 482 |
* Merge saved values over schema defaults. |
| 483 |
* |
| 484 |
* Unlike a plain `array_merge($defaults, $saved)`, an empty-string saved value |
| 485 |
* does NOT clobber a non-empty default. In this data model there is no null — |
| 486 |
* an empty string is the "never meaningfully set" marker that a partial or |
| 487 |
* legacy save leaves behind for fields the old form never submitted (a |
| 488 |
* switcher or button_set hidden behind a dependency, say). Letting `''` win |
| 489 |
* would silently blank out documented defaults for those users. |
| 490 |
* |
| 491 |
* Real choices are preserved: `'0'`/`false` (a switcher turned off), a |
| 492 |
* selected option string, an explicit empty array (a deliberately cleared |
| 493 |
* repeater) and any non-empty string all still override the default. Only the |
| 494 |
* ambiguous empty string yields. |
| 495 |
* |
| 496 |
* Nested fieldset objects are merged RECURSIVELY: a saved |
| 497 |
* `switcher_button_position` that only carries the sub-keys the form actually |
| 498 |
* submitted (an older save, or a sub-field hidden behind a dependency) still |
| 499 |
* resolves its missing sub-keys to their documented defaults, while every |
| 500 |
* saved sub-value wins. This makes the form resolve nested defaults the same |
| 501 |
* way the frontend already does (each nested read there has its own |
| 502 |
* fallback). The recursion is gated on BOTH sides being associative |
| 503 |
* (string-keyed) arrays, so repeater/group LISTS and scalars keep the plain |
| 504 |
* replace — a deliberately emptied or reordered list is honoured exactly as |
| 505 |
* before. |
| 506 |
*/ |
| 507 |
public function merge_defaults(array $defaults, array $saved): array |
| 508 |
{ |
| 509 |
$values = $defaults; |
| 510 |
foreach ($saved as $key => $value) { |
| 511 |
if ( |
| 512 |
$value === '' |
| 513 |
&& \array_key_exists($key, $defaults) |
| 514 |
&& $defaults[$key] !== '' |
| 515 |
&& $defaults[$key] !== null |
| 516 |
) { |
| 517 |
continue; // keep the non-empty default |
| 518 |
} |
| 519 |
if ( |
| 520 |
\is_array($value) |
| 521 |
&& isset($defaults[$key]) |
| 522 |
&& \is_array($defaults[$key]) |
| 523 |
&& $this->is_assoc_array($defaults[$key]) |
| 524 |
&& $this->is_assoc_array($value) |
| 525 |
) { |
| 526 |
$values[$key] = $this->merge_defaults($defaults[$key], $value); |
| 527 |
continue; |
| 528 |
} |
| 529 |
$values[$key] = $value; |
| 530 |
} |
| 531 |
return $values; |
| 532 |
} |
| 533 |
|
| 534 |
/** |
| 535 |
* Whether an array is associative (string-keyed) rather than a plain 0..n |
| 536 |
* list. Used to tell a fieldset's nested object (deep-merge) apart from a |
| 537 |
* repeater/group's list of rows (plain replace). An empty array is treated |
| 538 |
* as a list, so an intentionally cleared value replaces rather than merges. |
| 539 |
*/ |
| 540 |
protected function is_assoc_array(array $arr): bool |
| 541 |
{ |
| 542 |
if ($arr === []) { |
| 543 |
return false; |
| 544 |
} |
| 545 |
return \array_keys($arr) !== \range(0, \count($arr) - 1); |
| 546 |
} |
| 547 |
|
| 548 |
/** |
| 549 |
* Flatten id => type across a section list. |
| 550 |
*/ |
| 551 |
public function collect_field_types(array $sections): array |
| 552 |
{ |
| 553 |
$types = []; |
| 554 |
$this->walk_fields($sections, function ($field) use (&$types) { |
| 555 |
if (! empty($field['id']) && ! empty($field['type'])) { |
| 556 |
$types[$field['id']] = $field['type']; |
| 557 |
} |
| 558 |
}); |
| 559 |
return $types; |
| 560 |
} |
| 561 |
|
| 562 |
/** |
| 563 |
* Recursively visit every field across sections (including nested `fields` |
| 564 |
* and `section_tab` tabs). |
| 565 |
*/ |
| 566 |
protected function walk_fields(array $sections, callable $cb): void |
| 567 |
{ |
| 568 |
foreach ($sections as $section) { |
| 569 |
if (empty($section['fields']) || ! \is_array($section['fields'])) { |
| 570 |
continue; |
| 571 |
} |
| 572 |
foreach ($section['fields'] as $field) { |
| 573 |
if (! \is_array($field)) { |
| 574 |
continue; |
| 575 |
} |
| 576 |
$cb($field); |
| 577 |
if (! empty($field['fields']) && \is_array($field['fields'])) { |
| 578 |
$this->walk_fields([['fields' => $field['fields']]], $cb); |
| 579 |
} |
| 580 |
if (! empty($field['tabs']) && \is_array($field['tabs'])) { |
| 581 |
foreach ($field['tabs'] as $tab) { |
| 582 |
if (! empty($tab['fields']) && \is_array($tab['fields'])) { |
| 583 |
$this->walk_fields([['fields' => $tab['fields']]], $cb); |
| 584 |
} |
| 585 |
} |
| 586 |
} |
| 587 |
} |
| 588 |
} |
| 589 |
} |
| 590 |
|
| 591 |
// ─── Sanitization ─────────────────────────────────────────────────────────── |
| 592 |
|
| 593 |
/** |
| 594 |
* Type-aware sanitization of an incoming values map. |
| 595 |
* |
| 596 |
* `$type_map` is the FLAT id => type map produced by collect_field_types(), |
| 597 |
* which walks nested fields too — so a field's type is found by id no matter |
| 598 |
* how deeply it is nested. That matters: 26 of Darkify's value-bearing fields |
| 599 |
* live inside a `fieldset` or a `repeater` row (the brightness/grayscale |
| 600 |
* toggles and sliders, the image/video exclusion lists, the replacement |
| 601 |
* uploads…). Sanitizing those by position rather than by type would coerce |
| 602 |
* `true` to `'1'`, `100` to `'100'`, and — worst — strip the newlines out of |
| 603 |
* the multi-line exclusion lists. |
| 604 |
*/ |
| 605 |
public function sanitize_values(array $values, array $type_map): array |
| 606 |
{ |
| 607 |
$clean = []; |
| 608 |
foreach ($values as $key => $value) { |
| 609 |
$clean[$key] = $this->sanitize_value($value, $type_map[$key] ?? '', $type_map); |
| 610 |
} |
| 611 |
return $clean; |
| 612 |
} |
| 613 |
|
| 614 |
/** |
| 615 |
* Sanitize a single value by field type. |
| 616 |
* |
| 617 |
* Note the deliberate holes: `code_editor` and `textarea` carry the user's own |
| 618 |
* CSS and their newline-separated selector lists (Dark Mode CSS, Normal Mode |
| 619 |
* CSS, the element allow/deny lists). Running those through |
| 620 |
* `sanitize_text_field()` would collapse newlines and mangle the rules, so they |
| 621 |
* get the type-appropriate treatment instead. |
| 622 |
* |
| 623 |
* @param mixed $value The value to sanitize. |
| 624 |
* @param string $type The field type, if known. |
| 625 |
* @param array $type_map Flat id => type map, used to keep nested values |
| 626 |
* (fieldset children, repeater rows) type-aware. |
| 627 |
*/ |
| 628 |
protected function sanitize_value($value, string $type = '', array $type_map = []) |
| 629 |
{ |
| 630 |
// Preserved as-is rather than stringified to "": a cleared media field |
| 631 |
// sends null, and casting that to a string would put "" into the option |
| 632 |
// where the frontend expects either a URL or nothing. |
| 633 |
if ($value === null) { |
| 634 |
return null; |
| 635 |
} |
| 636 |
|
| 637 |
if (\is_array($value)) { |
| 638 |
$out = []; |
| 639 |
foreach ($value as $k => $v) { |
| 640 |
// A repeater's rows are a numeric list, so `$k` is an index and |
| 641 |
// resolves to no type — recursion then reaches the row object, |
| 642 |
// whose keys ARE field ids and do resolve. A fieldset's keys are |
| 643 |
// field ids directly. Either way the leaf is typed correctly. |
| 644 |
$key = \sanitize_text_field((string) $k); |
| 645 |
$out[$key] = $this->sanitize_value($v, $type_map[$key] ?? '', $type_map); |
| 646 |
} |
| 647 |
return $out; |
| 648 |
} |
| 649 |
|
| 650 |
switch ($type) { |
| 651 |
case 'textarea': |
| 652 |
return \sanitize_textarea_field((string) $value); |
| 653 |
|
| 654 |
case 'code_editor': |
| 655 |
// Raw CSS/JS the user authored. `wp_kses_post` would eat `>` in a |
| 656 |
// child selector (`.a > .b`), so the value is stored verbatim — |
| 657 |
// it is written only by `manage_options` users and is never |
| 658 |
// executed as PHP. |
| 659 |
return (string) $value; |
| 660 |
|
| 661 |
case 'switcher': |
| 662 |
case 'checkbox': |
| 663 |
return $value; // booleans / arrays already handled above |
| 664 |
|
| 665 |
case 'number': |
| 666 |
case 'slider': |
| 667 |
case 'spinner': |
| 668 |
return \is_numeric($value) ? $value + 0 : \sanitize_text_field((string) $value); |
| 669 |
|
| 670 |
case 'upload': |
| 671 |
return \esc_url_raw((string) $value); |
| 672 |
|
| 673 |
default: |
| 674 |
return \sanitize_text_field((string) $value); |
| 675 |
} |
| 676 |
} |
| 677 |
|
| 678 |
// ─── Pro-feature locking (free plugin) ────────────────────────────────────── |
| 679 |
|
| 680 |
/** |
| 681 |
* Whether a field config is Pro-locked in the free plugin. |
| 682 |
* |
| 683 |
* The free config files have always marked Pro-only fields with a CSS class |
| 684 |
* (`only_pro`, `switcher_pro_only`, `repeater_pro_only`) — the retired options |
| 685 |
* framework rendered those rows dimmed with an upgrade overlay. The class |
| 686 |
* markers remain the single source of truth, so the set of locked fields is |
| 687 |
* exactly the set the old admin locked; here they are translated into the |
| 688 |
* `pro: true` flag the React admin's ProLock UI consumes (same contract as |
| 689 |
* Chat Help). |
| 690 |
* |
| 691 |
* The marker convention is a class token that is `only_pro` or ends in |
| 692 |
* `_pro_only` (`switcher_pro_only`, `repeater_pro_only`, …). Matching the |
| 693 |
* whole family — rather than an explicit list — is what fixes the Replace |
| 694 |
* Images / Replace Videos repeaters: their `repeater_pro_only` marker was |
| 695 |
* silently ignored after the React migration (an earlier, narrower pattern |
| 696 |
* only matched a bare `pro_only` token), so those Pro-only repeaters wrongly |
| 697 |
* rendered as editable free fields even though the free frontend never had |
| 698 |
* any replacement logic to honour them. |
| 699 |
*/ |
| 700 |
protected function is_pro_field(array $field): bool |
| 701 |
{ |
| 702 |
$class = isset($field['class']) && \is_string($field['class']) ? $field['class'] : ''; |
| 703 |
return (bool) \preg_match('/(?:^|\s)(?:only_pro|[A-Za-z0-9_-]*pro_only)(?:\s|$)/', $class); |
| 704 |
} |
| 705 |
|
| 706 |
/** |
| 707 |
* Option keys inside a field's `options` map that are Pro-locked — the |
| 708 |
* config marks them with `'pro_only' => true` on the option row (e.g. the |
| 709 |
* Pro switch styles in SwitcherStyle.php). |
| 710 |
* |
| 711 |
* @return array<int,string> |
| 712 |
*/ |
| 713 |
protected function locked_option_keys(array $field): array |
| 714 |
{ |
| 715 |
$locked = []; |
| 716 |
if (! empty($field['options']) && \is_array($field['options'])) { |
| 717 |
foreach ($field['options'] as $key => $opt) { |
| 718 |
if (\is_array($opt) && ! empty($opt['pro_only'])) { |
| 719 |
$locked[] = (string) $key; |
| 720 |
} |
| 721 |
} |
| 722 |
} |
| 723 |
return $locked; |
| 724 |
} |
| 725 |
|
| 726 |
/** |
| 727 |
* Stamp `pro`/`pro_options` flags onto a normalized schema tree so the React |
| 728 |
* admin can render Pro-only fields and choices as locked previews — the same |
| 729 |
* flags Chat Help's free admin uses (see chat-help-react's ProLock). |
| 730 |
*/ |
| 731 |
public function apply_pro_flags(array $tree): array |
| 732 |
{ |
| 733 |
foreach ($tree as &$section) { |
| 734 |
if (! empty($section['fields']) && \is_array($section['fields'])) { |
| 735 |
$section['fields'] = $this->mark_pro_fields($section['fields']); |
| 736 |
} |
| 737 |
} |
| 738 |
unset($section); |
| 739 |
return $tree; |
| 740 |
} |
| 741 |
|
| 742 |
/** |
| 743 |
* Recursively add `pro`/`pro_options` flags to a field list (including |
| 744 |
* nested fieldset/group fields and section_tab tabs). |
| 745 |
* |
| 746 |
* @param array $fields Normalized field list. |
| 747 |
* @param bool $force Inherit a Pro lock from an enclosing field. |
| 748 |
*/ |
| 749 |
protected function mark_pro_fields(array $fields, bool $force = false): array |
| 750 |
{ |
| 751 |
foreach ($fields as &$field) { |
| 752 |
if (! \is_array($field)) { |
| 753 |
continue; |
| 754 |
} |
| 755 |
|
| 756 |
$field_pro = $force || $this->is_pro_field($field); |
| 757 |
if ($field_pro) { |
| 758 |
$field['pro'] = true; |
| 759 |
} |
| 760 |
|
| 761 |
$locked = $this->locked_option_keys($field); |
| 762 |
if (! empty($locked)) { |
| 763 |
$field['pro_options'] = $locked; |
| 764 |
} |
| 765 |
|
| 766 |
if (! empty($field['fields']) && \is_array($field['fields'])) { |
| 767 |
$field['fields'] = $this->mark_pro_fields($field['fields'], $field_pro); |
| 768 |
} |
| 769 |
if (! empty($field['tabs']) && \is_array($field['tabs'])) { |
| 770 |
foreach ($field['tabs'] as &$tab) { |
| 771 |
if (! empty($tab['fields']) && \is_array($tab['fields'])) { |
| 772 |
$tab['fields'] = $this->mark_pro_fields($tab['fields'], $force); |
| 773 |
} |
| 774 |
} |
| 775 |
unset($tab); |
| 776 |
} |
| 777 |
} |
| 778 |
unset($field); |
| 779 |
return $fields; |
| 780 |
} |
| 781 |
|
| 782 |
/** |
| 783 |
* Drop Pro-locked keys from an incoming values map before persisting, so a |
| 784 |
* crafted request can never flip a Pro-only setting in the free plugin. The |
| 785 |
* locked UI never submits these (its onChange is neutered) — this is |
| 786 |
* defence-in-depth, mirroring Chat Help's strip_pro_keys(). |
| 787 |
* |
| 788 |
* Locked fields are addressed by PATH, not just by top-level id: a fieldset |
| 789 |
* stores its children NESTED under its own id (see collect_defaults), so a |
| 790 |
* locked child inside an unlocked fieldset must be stripped inside that |
| 791 |
* nested object. Dropping the key (rather than blanking it) means the |
| 792 |
* caller's array_merge keeps whatever value is already stored. |
| 793 |
* |
| 794 |
* @param array $values Sanitized incoming values. |
| 795 |
* @param array $sections The registered sections for the option key. |
| 796 |
*/ |
| 797 |
public function strip_pro_keys(array $values, array $sections): array |
| 798 |
{ |
| 799 |
$tree = $this->apply_pro_flags($this->normalize_sections($sections)); |
| 800 |
$pro_paths = []; |
| 801 |
$locked_opts = []; |
| 802 |
foreach ($tree as $section) { |
| 803 |
if (! empty($section['fields']) && \is_array($section['fields'])) { |
| 804 |
$this->collect_pro_paths($section['fields'], [], $pro_paths, $locked_opts); |
| 805 |
} |
| 806 |
} |
| 807 |
|
| 808 |
foreach ($pro_paths as $path) { |
| 809 |
$this->unset_path($values, $path); |
| 810 |
} |
| 811 |
|
| 812 |
foreach ($locked_opts as $entry) { |
| 813 |
$current = $this->get_path($values, $entry['path'], $exists); |
| 814 |
if (! $exists) { |
| 815 |
continue; |
| 816 |
} |
| 817 |
if (\is_array($current)) { |
| 818 |
// Multi-value field: drop only the Pro-locked choices, keep the |
| 819 |
// free ones. |
| 820 |
$filtered = \array_values(\array_filter( |
| 821 |
$current, |
| 822 |
static function ($v) use ($entry) { |
| 823 |
return ! \in_array((string) $v, $entry['options'], true); |
| 824 |
} |
| 825 |
)); |
| 826 |
$this->set_path($values, $entry['path'], $filtered); |
| 827 |
continue; |
| 828 |
} |
| 829 |
if (\is_scalar($current) && \in_array((string) $current, $entry['options'], true)) { |
| 830 |
$this->unset_path($values, $entry['path']); |
| 831 |
} |
| 832 |
} |
| 833 |
|
| 834 |
return $values; |
| 835 |
} |
| 836 |
|
| 837 |
/** |
| 838 |
* Recursively gather the storage paths of Pro-locked fields and of fields |
| 839 |
* with Pro-locked option choices, from a flagged schema tree. |
| 840 |
* |
| 841 |
* A `fieldset` pushes its own id onto the path (its children save nested |
| 842 |
* under it); `section_tab` tabs save flat, so the path passes through. |
| 843 |
* `group`/`repeater` sub-fields never save as flat keys — their values nest |
| 844 |
* per-row inside the parent's own list, and a locked parent is stripped by |
| 845 |
* its own path — so their sub-fields are not descended into (same rationale |
| 846 |
* as Chat Help's collect_pro_ids). |
| 847 |
* |
| 848 |
* @param array $fields Flagged field list. |
| 849 |
* @param array $path Ids of the enclosing fieldsets. |
| 850 |
* @param array $pro_paths Out: paths of fully-locked fields. |
| 851 |
* @param array $locked_opts Out: list of ['path' => …, 'options' => …]. |
| 852 |
*/ |
| 853 |
protected function collect_pro_paths(array $fields, array $path, array &$pro_paths, array &$locked_opts): void |
| 854 |
{ |
| 855 |
foreach ($fields as $field) { |
| 856 |
if (! \is_array($field)) { |
| 857 |
continue; |
| 858 |
} |
| 859 |
$id = (string) ($field['id'] ?? ''); |
| 860 |
$type = (string) ($field['type'] ?? ''); |
| 861 |
|
| 862 |
if ($id !== '' && ! empty($field['pro'])) { |
| 863 |
$pro_paths[] = \array_merge($path, [$id]); |
| 864 |
} |
| 865 |
if ($id !== '' && ! empty($field['pro_options']) && \is_array($field['pro_options'])) { |
| 866 |
$locked_opts[] = [ |
| 867 |
'path' => \array_merge($path, [$id]), |
| 868 |
'options' => \array_map('strval', $field['pro_options']), |
| 869 |
]; |
| 870 |
} |
| 871 |
|
| 872 |
if (! empty($field['tabs']) && \is_array($field['tabs'])) { |
| 873 |
foreach ($field['tabs'] as $tab) { |
| 874 |
if (! empty($tab['fields']) && \is_array($tab['fields'])) { |
| 875 |
$this->collect_pro_paths($tab['fields'], $path, $pro_paths, $locked_opts); |
| 876 |
} |
| 877 |
} |
| 878 |
} |
| 879 |
|
| 880 |
if (! empty($field['fields']) && \is_array($field['fields'])) { |
| 881 |
if ($type === 'fieldset' && $id !== '') { |
| 882 |
$this->collect_pro_paths($field['fields'], \array_merge($path, [$id]), $pro_paths, $locked_opts); |
| 883 |
} elseif (! \in_array($type, ['group', 'repeater'], true)) { |
| 884 |
$this->collect_pro_paths($field['fields'], $path, $pro_paths, $locked_opts); |
| 885 |
} |
| 886 |
} |
| 887 |
} |
| 888 |
} |
| 889 |
|
| 890 |
/** Unset a nested key addressed by a path of ids. */ |
| 891 |
protected function unset_path(array &$values, array $path): void |
| 892 |
{ |
| 893 |
$last = \array_pop($path); |
| 894 |
$ref = &$values; |
| 895 |
foreach ($path as $step) { |
| 896 |
if (! isset($ref[$step]) || ! \is_array($ref[$step])) { |
| 897 |
return; |
| 898 |
} |
| 899 |
$ref = &$ref[$step]; |
| 900 |
} |
| 901 |
unset($ref[$last]); |
| 902 |
} |
| 903 |
|
| 904 |
/** |
| 905 |
* Read a nested value addressed by a path of ids. |
| 906 |
* |
| 907 |
* @param bool $exists Out: whether the full path resolved. |
| 908 |
* @return mixed |
| 909 |
*/ |
| 910 |
protected function get_path(array $values, array $path, &$exists) |
| 911 |
{ |
| 912 |
$exists = false; |
| 913 |
$cur = $values; |
| 914 |
foreach ($path as $step) { |
| 915 |
if (! \is_array($cur) || ! \array_key_exists($step, $cur)) { |
| 916 |
return null; |
| 917 |
} |
| 918 |
$cur = $cur[$step]; |
| 919 |
} |
| 920 |
$exists = true; |
| 921 |
return $cur; |
| 922 |
} |
| 923 |
|
| 924 |
/** Write a nested value addressed by a path of ids (path must resolve). */ |
| 925 |
protected function set_path(array &$values, array $path, $value): void |
| 926 |
{ |
| 927 |
$last = \array_pop($path); |
| 928 |
$ref = &$values; |
| 929 |
foreach ($path as $step) { |
| 930 |
if (! isset($ref[$step]) || ! \is_array($ref[$step])) { |
| 931 |
return; |
| 932 |
} |
| 933 |
$ref = &$ref[$step]; |
| 934 |
} |
| 935 |
$ref[$last] = $value; |
| 936 |
} |
| 937 |
} |
| 938 |
|