PluginProbe
WebberZone Top 10 — Popular Posts / trunk
WebberZone Top 10 — Popular Posts vtrunk
4.5.0 4.4.3 4.4.2 4.4.1 4.4.0 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 trunk 1.0 1.0.1 1.1 1.2 1.3 1.4 1.4.1 1.5 1.5.1 1.5.2 1.5.3 1.6 1.6.1 1.6.2 All 116 releases
top-10 / includes / admin / settings / class-settings-api.php

class-settings-api.php in WebberZone Top 10 — Popular Posts trunk, at includes/admin/settings/class-settings-api.php

1,331 lines 39.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings API.
4 *
5 * Functions to register, read, write and update settings.
6 * Portions of this code have been inspired by Easy Digital Downloads, WordPress Settings Sandbox, WordPress Settings API class, etc.
7 *
8 * @package WebberZone\Top_Ten
9 */
10
11 namespace WebberZone\Top_Ten\Admin\Settings;
12
13 // If this file is called directly, abort.
14 if ( ! defined( 'WPINC' ) ) {
15 die;
16 }
17
18 /**
19 * Settings API wrapper class
20 *
21 * @version 3.0.0
22 * @since 4.0.0
23 */
24 class Settings_API {
25
26 /**
27 * Current version number
28 *
29 * @var string
30 */
31 public const VERSION = '3.0.0';
32
33 /**
34 * Settings Key.
35 *
36 * @var string Settings Key.
37 */
38 public $settings_key;
39
40 /**
41 * Prefix which is used for creating the unique filters and actions.
42 *
43 * @var string Prefix.
44 */
45 public $prefix;
46
47 /**
48 * Translation strings.
49 *
50 * @see set_translation_strings()
51 *
52 * @var array Translation strings.
53 */
54 public $translation_strings;
55
56 /**
57 * Menus.
58 *
59 * @var array Menus.
60 */
61 public $menus = array();
62
63 /**
64 * Menu pages.
65 *
66 * @var array Menu pages.
67 */
68 public $menu_pages = array();
69
70 /**
71 * Default navigation tab.
72 *
73 * @var string Default navigation tab.
74 */
75 protected $default_tab;
76
77 /**
78 * Version used for cache-busting enqueued assets. Pass the plugin's own
79 * version via props so every plugin release refreshes browser caches.
80 *
81 * @var string
82 */
83 protected $version = self::VERSION;
84
85 /**
86 * Settings page.
87 *
88 * @var string Settings page.
89 */
90 public $settings_page = '';
91
92 /**
93 * Admin Footer Text. Displayed at the bottom of the plugin settings page.
94 *
95 * @var string Admin Footer Text.
96 */
97 protected $admin_footer_text;
98
99 /**
100 * Array containing the settings' sections.
101 *
102 * @var array Settings sections array.
103 */
104 protected $settings_sections = array();
105
106 /**
107 * Array containing the settings' fields.
108 *
109 * @var array Settings fields array.
110 */
111 protected $registered_settings = array();
112
113 /**
114 * Array containing the settings' fields that need to be upgraded to the current Settings API.
115 *
116 * @var array Settings fields array.
117 */
118 protected $upgraded_settings = array();
119
120 /**
121 * Help sidebar content.
122 *
123 * @var string Admin Footer Text.
124 */
125 protected $help_sidebar;
126
127 /**
128 * Array of help tabs.
129 *
130 * @var array Settings sections array.
131 */
132 protected $help_tabs = array();
133
134 /**
135 * Settings form.
136 *
137 * @var object Settings form.
138 */
139 public $settings_form;
140
141 /**
142 * Main constructor class.
143 *
144 * @param string $settings_key Settings key.
145 * @param string $prefix Prefix. Used for actions and filters.
146 * @param mixed $args {
147 * Array or string of arguments. Default is blank array.
148 * @type array $translation_strings Translation strings.
149 * @type array $settings_sections Settings sections.
150 * @type array $props Properties.
151 * @type array $registered_settings Registered settings.
152 * @type array $upgraded_settings Upgraded settings.
153 * }
154 */
155 public function __construct( $settings_key, $prefix, $args ) {
156
157 if ( ! defined( 'WZ_SETTINGS_API_VERSION' ) ) {
158 define( 'WZ_SETTINGS_API_VERSION', self::VERSION ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound
159 }
160
161 $this->settings_key = $settings_key;
162 $this->prefix = $prefix;
163
164 $defaults = array(
165 'translation_strings' => array(),
166 'props' => array(),
167 'settings_sections' => array(),
168 'registered_settings' => array(),
169 'upgraded_settings' => array(),
170 );
171 $args = wp_parse_args( $args, $defaults );
172
173 $this->hooks();
174 $this->set_translation_strings( $args['translation_strings'] );
175 $this->set_props( $args['props'] );
176 $this->set_sections( $args['settings_sections'] );
177 $this->set_registered_settings( $args['registered_settings'] );
178 $this->set_upgraded_settings( $args['upgraded_settings'] );
179 }
180
181 /**
182 * Adds the functions to the appropriate WordPress hooks.
183 */
184 public function hooks() {
185 add_action( 'admin_menu', array( $this, 'admin_menu' ), 11 );
186 add_action( 'admin_init', array( $this, 'admin_init' ) );
187 add_filter( 'admin_footer_text', array( $this, 'admin_footer_text' ) );
188 add_action( 'admin_enqueue_scripts', array( $this, 'admin_enqueue_scripts' ) );
189 add_filter( 'admin_body_class', array( $this, 'admin_body_class' ) );
190 }
191
192 /**
193 * Filters the CSS classes for the body tag in the admin.
194 *
195 * @param string $classes Space-separated list of CSS classes.
196 * @return string Space-separated list of CSS classes.
197 */
198 public function admin_body_class( $classes ) {
199 $current_screen = get_current_screen();
200
201 if ( in_array( $current_screen->id, $this->menu_pages, true ) ) {
202 $classes .= " {$this->prefix}-dashboard-page";
203 }
204 return $classes;
205 }
206
207 /**
208 * Sets properties.
209 *
210 * @param array|string $args {
211 * Array or string of arguments. Default is blank array.
212 *
213 * @type array $menus Array of admin menus. See add_custom_menu_page() for more info.
214 * @type string $default_tab Default tab.
215 * @type string $admin_footer_text Admin footer text.
216 * @type string $help_sidebar Help sidebar.
217 * @type array $help_tabs Help tabs.
218 * }
219 */
220 public function set_props( $args ) {
221
222 $defaults = array(
223 'menus' => array(),
224 'default_tab' => 'general',
225 'admin_footer_text' => '',
226 'help_sidebar' => '',
227 'help_tabs' => array(),
228 'version' => self::VERSION,
229 );
230
231 $args = wp_parse_args( $args, $defaults );
232
233 foreach ( $args as $name => $value ) {
234 $this->$name = $value;
235 }
236 }
237
238 /**
239 * Sets translation strings.
240 *
241 * @param array $strings {
242 * Array of translation strings.
243 *
244 * @type string $page_title Page title.
245 * @type string $menu_title Menu title.
246 * @type string $page_header Page header.
247 * @type string $reset_message Reset message.
248 * @type string $success_message Success message.
249 * @type string $save_changes Save changes button label.
250 * @type string $reset_settings Reset settings button label.
251 * @type string $reset_button_confirm Reset button confirmation message.
252 * }
253 *
254 * @return void
255 */
256 public function set_translation_strings( $strings ) {
257
258 // Args prefixed with an underscore are reserved for internal use.
259 $defaults = array(
260 'page_header' => '',
261 'reset_message' => 'Settings have been reset to their default values. Reload this page to view the updated settings.',
262 'success_message' => 'Settings updated.',
263 'save_changes' => 'Save Changes',
264 'reset_settings' => 'Reset all settings',
265 'reset_button_confirm' => 'Do you really want to reset all these settings to their default values?',
266 'button_label' => 'Choose File',
267 'previous_saved' => 'Previously saved',
268 'repeater_new_item' => 'New Item',
269 'required_label' => 'Required',
270 'tom_select_no_results' => 'No results found for "%s"',
271 'search_placeholder' => 'Search settings',
272 'search_no_results' => 'No settings found. Try a different search term.',
273 'search_clear' => 'Clear search',
274 'search_results_single' => '%d setting found.',
275 'search_results_plural' => '%d settings found.',
276 'search_matches_label' => 'matching settings',
277 );
278
279 $strings = wp_parse_args( $strings, $defaults );
280
281 $this->translation_strings = $strings;
282 }
283
284 /**
285 * Set settings sections
286 *
287 * @param array $sections Setting sections array in the format of: id => Title.
288 * @return object Class object.
289 */
290 public function set_sections( $sections ) {
291 $this->settings_sections = (array) $sections;
292
293 return $this;
294 }
295
296 /**
297 * Add a single section
298 *
299 * @param array $section New Section.
300 * @return object Object of the class instance.
301 */
302 public function add_section( $section ) {
303 $this->settings_sections[] = $section;
304
305 return $this;
306 }
307
308 /**
309 * Set the settings fields for registered settings.
310 *
311 * @param array $registered_settings {
312 * Array of settings in format id => attributes.
313 * @type string $section Section title.
314 * @type string $id Field ID.
315 * @type string $name Field name.
316 * @type string $desc Field description.
317 * @type string $type Field type.
318 * @type string $options Field default option(s).
319 * @type string $max Field max. Applicable for numbers.
320 * @type string $min Field min. Applicable for numbers.
321 * @type string $step Field step. Applicable for numbers.
322 * @type string $size Field size. Applicable for text and textarea.
323 * @type string $field_class CSS class.
324 * @type array $field_attributes HTML Attributes in the form of attribute => value.
325 * @type string $placeholder Placeholder. Applicable for text and textarea.
326 * @type string $sanitize_callback Sanitize callback.
327 * }
328 * }
329 * }
330 * @return object Object of the class instance.
331 */
332 public function set_registered_settings( $registered_settings ) {
333 $this->registered_settings = (array) $registered_settings;
334
335 return $this;
336 }
337
338 /**
339 * Set the settings fields for settings to upgrade.
340 *
341 * @param array $upgraded_settings Settings array.
342 * @return object Object of the class instance.
343 */
344 public function set_upgraded_settings( $upgraded_settings = array() ) {
345 $this->upgraded_settings = (array) $upgraded_settings;
346
347 return $this;
348 }
349
350 /**
351 * Add a menu page to the WordPress admin area.
352 *
353 * @param array $menu Array of settings for the menu page.
354 *
355 * @return string|false The resulting page’s hook_suffix, or false if the user does not have the capability required.
356 */
357 public function add_custom_menu_page( $menu ) {
358 $defaults = array(
359
360 // Modes: submenu, management, options, theme, plugins, users, dashboard, posts, media, links, pages, comments.
361 'type' => 'submenu',
362
363 // Submenu default settings.
364 'parent_slug' => 'options-general.php',
365 'page_title' => '',
366 'menu_title' => '',
367 'capability' => $this->get_capability_for_menu(),
368 'menu_slug' => '',
369 'function' => array( $this, 'plugin_settings' ),
370
371 // Menu default settings.
372 'icon_url' => 'dashicons-admin-generic',
373 'position' => null,
374
375 );
376 $menu = wp_parse_args( $menu, $defaults );
377
378 $menu_page = false;
379
380 switch ( $menu['type'] ) {
381 case 'submenu':
382 $menu_page = add_submenu_page(
383 $menu['parent_slug'],
384 $menu['page_title'],
385 $menu['menu_title'],
386 $menu['capability'],
387 $menu['menu_slug'],
388 $menu['function'],
389 $menu['position']
390 );
391 break;
392 case 'management':
393 case 'options':
394 case 'theme':
395 case 'plugins':
396 case 'users':
397 case 'dashboard':
398 case 'posts':
399 case 'media':
400 case 'links':
401 case 'pages':
402 case 'comments':
403 $f = 'add_' . $menu['type'] . '_page';
404 if ( function_exists( $f ) ) {
405 $menu_page = $f(
406 $menu['page_title'],
407 $menu['menu_title'],
408 $menu['capability'],
409 $menu['menu_slug'],
410 $menu['function'],
411 $menu['position']
412 );
413 }
414 break;
415 default:
416 $menu_page = add_menu_page(
417 $menu['page_title'],
418 $menu['menu_title'],
419 $menu['capability'],
420 $menu['menu_slug'],
421 $menu['function'],
422 $menu['icon_url'],
423 $menu['position']
424 );
425 break;
426 }
427
428 return $menu_page;
429 }
430
431
432 /**
433 * Add admin menu.
434 */
435 public function admin_menu() {
436 foreach ( $this->menus as $menu ) {
437 $menu_page = $this->add_custom_menu_page( $menu );
438
439 $this->menu_pages[ $menu['menu_slug'] ] = $menu_page;
440 if ( isset( $menu['settings_page'] ) && $menu['settings_page'] ) {
441 $this->settings_page = $menu_page;
442 }
443 }
444
445 // Load the settings contextual help.
446 add_action( 'load-' . $this->settings_page, array( $this, 'settings_help' ) );
447 }
448
449 /**
450 * Get the appropriate capability for the menu based on the user's roles and settings.
451 *
452 * @param array $roles Array of roles to check.
453 * @param string $base_capability The default capability.
454 * @param \WP_User $current_user The current user object.
455 * @param array $role_capabilities Array of role capabilities.
456 * @return string The capability to use for the menu.
457 */
458 public static function get_capability_for_menu( $roles = array(), $base_capability = 'manage_options', $current_user = null, $role_capabilities = array() ) {
459 if ( ! $current_user ) {
460 $current_user = wp_get_current_user();
461 }
462
463 if ( empty( $roles ) || in_array( 'administrator', $current_user->roles, true ) ) {
464 return $base_capability;
465 }
466
467 if ( empty( $role_capabilities ) ) {
468 $role_capabilities = array(
469 'editor' => 'edit_others_posts',
470 'author' => 'publish_posts',
471 'contributor' => 'edit_posts',
472 'subscriber' => 'read',
473 );
474 }
475
476 foreach ( $current_user->roles as $role ) {
477 if ( in_array( $role, $roles, true ) && isset( $role_capabilities[ $role ] ) ) {
478 return $role_capabilities[ $role ];
479 }
480 }
481
482 return $base_capability;
483 }
484
485 /**
486 * Enqueue scripts and styles.
487 *
488 * @param string $hook The current admin page.
489 */
490 public function admin_enqueue_scripts( $hook ) {
491
492 $minimize = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
493
494 // Settings API scripts.
495 wp_register_script(
496 'wz-' . $this->prefix . '-admin',
497 plugins_url( 'js/settings-admin-scripts' . $minimize . '.js', __FILE__ ),
498 array( 'jquery', 'wp-color-picker', 'jquery-ui-tabs' ),
499 $this->version,
500 true
501 );
502 wp_register_script(
503 'wz-' . $this->prefix . '-codemirror',
504 plugins_url( 'js/apply-cm' . $minimize . '.js', __FILE__ ),
505 array( 'jquery', 'underscore', 'code-editor' ),
506 $this->version,
507 true
508 );
509 wp_register_script(
510 'wz-' . $this->prefix . '-media-selector',
511 plugins_url( 'js/media-selector' . $minimize . '.js', __FILE__ ),
512 array( 'jquery', 'media-editor', 'media-views' ),
513 $this->version,
514 true
515 );
516 wp_register_style(
517 'wz-' . $this->prefix . '-admin',
518 plugins_url( 'css/admin-style' . $minimize . '.css', __FILE__ ),
519 array( 'wp-color-picker' ),
520 $this->version
521 );
522
523 // Tom Select scripts and styles.
524 wp_register_style(
525 'wz-' . $this->prefix . '-tom-select',
526 plugins_url( 'css/tom-select.min.css', __FILE__ ),
527 array(),
528 $this->version
529 );
530 wp_register_script(
531 'wz-' . $this->prefix . '-tom-select',
532 plugins_url( 'js/tom-select.complete.min.js', __FILE__ ),
533 array( 'jquery' ),
534 $this->version,
535 true
536 );
537 wp_register_script(
538 'wz-' . $this->prefix . '-tom-select-init',
539 plugin_dir_url( __FILE__ ) . 'js/tom-select-init' . $minimize . '.js',
540 array( 'jquery', 'wz-' . $this->prefix . '-tom-select' ),
541 $this->version,
542 true
543 );
544 wp_localize_script(
545 "wz-{$this->prefix}-admin",
546 'WebberSettingsAdmin',
547 array(
548 'prefix' => $this->prefix,
549 'settings_key' => $this->settings_key,
550 'strings' => array(
551 'search_no_results' => esc_html( $this->translation_strings['search_no_results'] ?? 'No settings found. Try a different search term.' ),
552 'search_results_single' => esc_html( $this->translation_strings['search_results_single'] ?? '%d setting found.' ),
553 'search_results_plural' => esc_html( $this->translation_strings['search_results_plural'] ?? '%d settings found.' ),
554 'search_matches_label' => esc_html( $this->translation_strings['search_matches_label'] ?? 'matching settings' ),
555 ),
556 )
557 );
558
559 if ( $hook === $this->settings_page ) {
560 $args = array(
561 'strings' => array(
562 'no_results' => isset( $this->translation_strings['tom_select_no_results'] ) ? esc_html( $this->translation_strings['tom_select_no_results'] ) : 'No results found for "%s"',
563 ),
564 );
565 self::enqueue_scripts_styles( $this->prefix, $args );
566 }
567 }
568
569 /**
570 * Enqueues all scripts, styles, settings, and templates necessary to use the Settings API.
571 *
572 * @param string $prefix Prefix which is used for creating the unique filters and actions.
573 * @param array $args Array of arguments.
574 */
575 public static function enqueue_scripts_styles( $prefix, $args = array() ) {
576
577 wp_enqueue_media();
578
579 wp_enqueue_code_editor(
580 array(
581 'type' => 'text/html',
582 'codemirror' => array(
583 'indentUnit' => 2,
584 'tabSize' => 2,
585 ),
586 )
587 );
588
589 wp_enqueue_script( "wz-{$prefix}-admin" );
590 wp_enqueue_script( "wz-{$prefix}-codemirror" );
591 wp_enqueue_script( "wz-{$prefix}-media-selector" );
592
593 // Enqueue Tom Select.
594 wp_enqueue_style( "wz-{$prefix}-tom-select" );
595 wp_enqueue_script( "wz-{$prefix}-tom-select" );
596
597 $defaults = array(
598 'endpoint' => 'category',
599 'strings' => array(
600 'no_results' => 'No results found for "%s"',
601 ),
602 );
603
604 $args = wp_parse_args( $args, $defaults );
605
606 // Localize Tom Select settings.
607 wp_localize_script(
608 "wz-{$prefix}-tom-select-init",
609 'WebberTomSelectSettings',
610 $args
611 );
612 wp_enqueue_script( "wz-{$prefix}-tom-select-init" );
613
614 wp_enqueue_style( 'wz-' . $prefix . '-admin' );
615 }
616
617 /**
618 * Initialize and registers the settings sections and fields to WordPress
619 *
620 * Usually this should be called at `admin_init` hook.
621 *
622 * This public function gets the initiated settings sections and fields. Then
623 * registers them to WordPress and ready for use.
624 */
625 public function admin_init() {
626
627 $settings_key = $this->settings_key;
628
629 if ( false === get_option( $settings_key ) ) {
630 add_option( $settings_key, $this->settings_defaults() );
631 }
632
633 $this->settings_form = new Settings_Form(
634 array(
635 'settings_key' => $settings_key,
636 'prefix' => $this->prefix,
637 'translation_strings' => $this->translation_strings,
638 )
639 );
640
641 foreach ( $this->registered_settings as $section => $settings ) {
642
643 add_settings_section(
644 "{$settings_key}_{$section}", // ID used to identify this section and with which to register options.
645 '', // No title, we will handle this via a separate function.
646 '__return_false', // No callback function needed. We'll process this separately.
647 "{$settings_key}_{$section}" // Page on which these options will be added.
648 );
649
650 foreach ( $settings as $setting ) {
651
652 $args = self::parse_field_args( $setting, $section );
653
654 $id = $args['id'];
655 $name = $args['name'];
656 $type = isset( $args['type'] ) ? $args['type'] : 'text';
657 $callback = method_exists( $this->settings_form, "callback_{$type}" ) ? array( $this->settings_form, "callback_{$type}" ) : array( $this->settings_form, 'callback_missing' );
658
659 // Tag header rows so the settings search can group fields under them.
660 if ( 'header' === $type ) {
661 $args['class'] = trim( ( $args['class'] ?? '' ) . ' wz-settings-header-row', " \t\n\r\0\x0B" );
662 }
663
664 add_settings_field(
665 "{$settings_key}[{$id}]", // ID of the settings field. We save it within the settings array.
666 $name, // Label of the setting.
667 $callback, // Function to handle the setting.
668 "{$settings_key}_{$section}", // Page to display the setting. In our case it is the section as defined above.
669 "{$settings_key}_{$section}", // Name of the section.
670 $args
671 );
672 }
673 }
674
675 // Register the settings into the options table.
676 register_setting(
677 $settings_key,
678 $settings_key,
679 array(
680 'type' => 'object',
681 'default' => $this->settings_defaults(),
682 'sanitize_callback' => array( $this, 'settings_sanitize' ),
683 // The value is an open-ended map with no REST schema, and settings_sanitize() expects a form submission.
684 'show_in_rest' => false,
685 )
686 );
687 }
688
689 /**
690 * Flattens $this->registered_settings into $setting[id] => $setting[type] format.
691 *
692 * @return array Default settings
693 */
694 public function get_registered_settings_types() {
695
696 $options = array();
697
698 // Populate some default values.
699 foreach ( $this->registered_settings as $tab => $settings ) {
700 foreach ( $settings as $option ) {
701 $options[ $option['id'] ] = $option['type'];
702 }
703 }
704
705 /**
706 * Filters the settings array.
707 *
708 * @param array $options Default settings.
709 */
710 return apply_filters( $this->prefix . '_get_settings_types', $options ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
711 }
712
713
714 /**
715 * Default settings.
716 *
717 * @return array Default settings
718 */
719 public function settings_defaults() {
720
721 $options = array();
722
723 // Populate some default values.
724 foreach ( $this->registered_settings as $tab => $settings ) {
725 foreach ( $settings as $option ) {
726 /**
727 * Skip settings that are not really settings.
728 *
729 * @param array $non_setting_types Array of types which are not settings.
730 */
731 $non_setting_types = apply_filters( $this->prefix . '_non_setting_types', array( 'header', 'descriptive_text' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
732
733 if ( in_array( $option['type'], $non_setting_types, true ) ) {
734 continue;
735 }
736
737 // Base default per type.
738 $options[ $option['id'] ] = ( 'checkbox' === $option['type'] ) ? 0 : '';
739
740 // Prefer the explicit 'default' key when provided.
741 if ( isset( $option['default'] ) ) {
742 $options[ $option['id'] ] = $option['default'];
743 } else {
744 // Back-compat for legacy configs that used 'options' to store default values for text-like fields.
745 if ( in_array( $option['type'], array( 'textarea', 'css', 'html', 'text', 'url', 'csv', 'color', 'numbercsv', 'postids', 'posttypes', 'number', 'wysiwyg', 'file', 'password' ), true ) && isset( $option['options'] ) ) {
746 $options[ $option['id'] ] = $option['options'];
747 }
748
749 // Back-compat: when checkbox used 'options' truthy to indicate checked by default.
750 if ( 'checkbox' === $option['type'] && ! empty( $option['options'] ) ) {
751 $options[ $option['id'] ] = 1;
752 }
753 }
754 }
755 }
756
757 $options = array_merge( $options, $this->upgraded_settings );
758
759 /**
760 * Filters the default settings array.
761 *
762 * @param array $options Default settings.
763 */
764 return apply_filters( $this->prefix . '_settings_defaults', $options ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
765 }
766
767
768 /**
769 * Get the default option for a specific key
770 *
771 * @param string $key Key of the option to fetch.
772 * @return mixed
773 */
774 public function get_default_option( $key = '' ) {
775
776 $default_settings = $this->settings_defaults();
777
778 if ( array_key_exists( $key, $default_settings ) ) {
779 return $default_settings[ $key ];
780 } else {
781 return false;
782 }
783 }
784
785
786 /**
787 * Reset settings.
788 *
789 * @return void
790 */
791 public function settings_reset() {
792 delete_option( $this->settings_key );
793 }
794
795 /**
796 * Get sanitization callback for given Settings key.
797 *
798 * @param string $key Settings key.
799 *
800 * @return mixed Callback function or false if callback isn't found.
801 */
802 public function get_sanitize_callback( $key = '' ) {
803 if ( empty( $key ) ) {
804 return false;
805 }
806
807 $settings_sanitize = new Settings_Sanitize(
808 array(
809 'settings_key' => $this->settings_key,
810 'prefix' => $this->prefix,
811 )
812 );
813
814 // Iterate over registered fields and see if we can find proper callback.
815 foreach ( $this->registered_settings as $section => $settings ) {
816 foreach ( $settings as $setting ) {
817 if ( $setting['id'] !== $key ) {
818 continue;
819 }
820
821 if ( isset( $setting['sanitize_callback'] ) && is_callable( $setting['sanitize_callback'] ) ) {
822 return $setting['sanitize_callback'];
823 }
824
825 $method = 'sanitize_' . $setting['type'] . '_field';
826
827 // Field types with no callback of their own must still not store raw input.
828 if ( ! is_callable( array( $settings_sanitize, $method ) ) ) {
829 $method = 'sanitize_missing';
830 }
831
832 // Every callback receives the field configuration so choice fields can validate against their own options.
833 return function ( $value ) use ( $settings_sanitize, $method, $setting ) {
834 return $settings_sanitize->$method( $value, $setting );
835 };
836 }
837 }
838
839 return false;
840 }
841
842 /**
843 * Get the settings keys that are rendered locked (disabled or pro-gated).
844 *
845 * @return array Map of settings key => true for each locked setting.
846 */
847 public function get_locked_settings() {
848 $locked = array();
849
850 foreach ( $this->registered_settings as $settings ) {
851 foreach ( $settings as $setting ) {
852 if ( isset( $setting['id'] ) && ( ! empty( $setting['disabled'] ) || ! empty( $setting['pro'] ) ) ) {
853 $locked[ $setting['id'] ] = true;
854 }
855 }
856 }
857
858 return $locked;
859 }
860
861 /**
862 * Sanitize the form data being submitted.
863 *
864 * @param mixed $input Unsanitized input. An array for form submissions, but REST and WP-CLI may pass anything.
865 * @return array Sanitized array
866 */
867 public function settings_sanitize( $input ) {
868 // Set when a classic form is submitted; used only to pick the active tab below.
869 $referrer = array();
870
871 if ( ! empty( $_POST['_wp_http_referer'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
872 parse_str( sanitize_text_field( wp_unslash( $_POST['_wp_http_referer'] ) ), $referrer ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
873 }
874
875 // Check if we need to set to defaults.
876 $reset = isset( $_POST['settings_reset'] ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
877
878 if ( $reset ) {
879 $this->settings_reset();
880 $settings = get_option( $this->settings_key );
881
882 add_settings_error( $this->prefix . '-notices', '', $this->translation_strings['reset_message'], 'error' );
883
884 return $settings;
885 }
886
887 // Get the various settings we've registered.
888 $settings = get_option( $this->settings_key );
889 $settings = is_array( $settings ) ? $settings : array();
890 $settings_types = $this->get_registered_settings_types();
891 $locked = $this->get_locked_settings();
892
893 // Get the tab. This is also our settings' section.
894 $tab = $referrer['tab'] ?? $this->default_tab;
895
896 $input = is_array( $input ) ? $input : array();
897
898 /**
899 * Filter the settings for the tab. e.g. prefix_settings_general_sanitize.
900 *
901 * @param array $input Input unclean array
902 */
903 $input = apply_filters( $this->prefix . '_settings_' . $tab . '_sanitize', $input ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
904
905 // Start from what is stored. Submitted values are merged back in below, once sanitized.
906 $output = $settings;
907
908 // Loop through each setting being saved and pass it through a sanitization filter.
909 foreach ( $settings_types as $key => $type ) {
910 /**
911 * Skip settings that are not really settings.
912 *
913 * @param array $non_setting_types Array of types which are not settings.
914 */
915 $non_setting_types = apply_filters( $this->prefix . '_non_setting_types', array( 'header', 'descriptive_text' ) ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
916
917 if ( in_array( $type, $non_setting_types, true ) ) {
918 continue;
919 }
920
921 if ( array_key_exists( $key, $input ) ) {
922 $sanitize_callback = $this->get_sanitize_callback( $key );
923
924 // If callback is set, call it.
925 if ( $sanitize_callback ) {
926 $output[ $key ] = call_user_func( $sanitize_callback, $input[ $key ] );
927 continue;
928 }
929 }
930
931 // Delete any key that is not present when we submit the input array.
932 if ( ! isset( $input[ $key ] ) ) {
933 // Disabled fields are never submitted, so a missing key must not delete them.
934 if ( ! isset( $locked[ $key ] ) ) {
935 unset( $output[ $key ] );
936 }
937 }
938 }
939
940 // Keys added by the tab filter are not registered settings, but must not be stored raw either.
941 $settings_sanitize = new Settings_Sanitize(
942 array(
943 'settings_key' => $this->settings_key,
944 'prefix' => $this->prefix,
945 )
946 );
947
948 foreach ( array_diff_key( $input, $settings_types ) as $key => $value ) {
949 $output[ sanitize_text_field( (string) $key ) ] = $settings_sanitize->sanitize_missing( $value );
950 }
951
952 add_settings_error( $this->prefix . '-notices', '', $this->translation_strings['success_message'], 'updated' );
953
954 /**
955 * Filter the settings array before it is returned.
956 *
957 * @param array $output Settings array.
958 * @param array $input Input settings array.
959 */
960 return apply_filters( $this->prefix . '_settings_sanitize', $output, $input ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
961 }
962
963 /**
964 * Render the settings page.
965 */
966 public function plugin_settings() {
967 ?>
968 <div class="wrap">
969 <?php do_action( $this->prefix . '_settings_page_header_before' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
970 <h1><?php echo esc_html( $this->translation_strings['page_header'] ); ?></h1>
971 <?php do_action( $this->prefix . '_settings_page_header' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound ?>
972
973 <?php
974 // WordPress automatically calls settings_errors() on Settings pages.
975 // Only call it manually on custom menu pages to prevent duplicates.
976 $current_screen = get_current_screen();
977 if ( $current_screen && 0 !== strpos( $current_screen->base, 'settings_page_' ) ) {
978 settings_errors( $this->prefix . '-notices' );
979 }
980 ?>
981
982 <div id="poststuff">
983 <div id="post-body" class="metabox-holder columns-2 wz-settings-post-body">
984 <div id="post-body-content" class="wz-vertical-tabs">
985
986 <?php $this->show_navigation(); ?>
987 <?php $this->show_form(); ?>
988
989 </div><!-- /#post-body-content -->
990
991 <div id="postbox-container-1" class="postbox-container">
992
993 <div id="side-sortables" class="meta-box-sortables ui-sortable">
994 <?php
995 $sidebar_file = dirname( __DIR__ ) . '/sidebar.php';
996 if ( file_exists( $sidebar_file ) ) {
997 include_once $sidebar_file;
998 }
999 ?>
1000 </div><!-- /#side-sortables -->
1001
1002 </div><!-- /#postbox-container-1 -->
1003 </div><!-- /#post-body -->
1004 <br class="clear" />
1005 </div><!-- /#poststuff -->
1006
1007 </div><!-- /.wrap -->
1008
1009 <?php
1010 }
1011
1012 /**
1013 * Show navigations as tab
1014 *
1015 * Shows all the settings section labels as tab
1016 */
1017 public function show_navigation() {
1018 $active_tab = isset( $_GET['tab'] ) && array_key_exists( sanitize_key( wp_unslash( $_GET['tab'] ) ), $this->settings_sections ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : $this->default_tab; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended
1019
1020 $count = count( $this->settings_sections );
1021
1022 // Don't show the navigation if only one section exists.
1023 if ( 1 === $count ) {
1024 return;
1025 }
1026
1027 $html = '<ul class="nav-tab-wrapper">';
1028
1029 // Settings search box. Rendered via wp_kses() with an extended allowed list below as
1030 // wp_kses_post() strips input and button tags.
1031 $search_box = sprintf(
1032 '<li class="wz-settings-search-wrap">' .
1033 '<label class="screen-reader-text" for="%1$s">%2$s</label>' .
1034 '<span class="wz-settings-search-box">' .
1035 '<input type="search" id="%1$s" class="wz-settings-search" placeholder="%2$s" autocomplete="off" />' .
1036 '<button type="button" class="wz-settings-search-clear" aria-label="%3$s" hidden><span aria-hidden="true">&times;</span></button>' .
1037 '</span>' .
1038 '<span class="wz-settings-search-status screen-reader-text" role="status" aria-live="polite"></span>' .
1039 '</li>',
1040 esc_attr( "{$this->prefix}-settings-search" ),
1041 esc_attr( $this->translation_strings['search_placeholder'] ?? 'Search settings' ),
1042 esc_attr( $this->translation_strings['search_clear'] ?? 'Clear search' )
1043 );
1044
1045 foreach ( $this->settings_sections as $tab_id => $tab_name ) {
1046
1047 $active = $active_tab === $tab_id ? ' ' : '';
1048
1049 $html .= sprintf(
1050 '<li><a href="#%s" title="%s" class="nav-tab %s">%s</a></li>',
1051 esc_attr( $tab_id ),
1052 esc_attr( $tab_name ),
1053 sanitize_html_class( $active ),
1054 esc_html( $tab_name )
1055 );
1056
1057 }
1058
1059 $html .= '</ul>';
1060
1061 $allowed_html = wp_kses_allowed_html( 'post' );
1062 $allowed_html['input'] = array(
1063 'type' => true,
1064 'id' => true,
1065 'class' => true,
1066 'placeholder' => true,
1067 'autocomplete' => true,
1068 );
1069 $allowed_html['button'] = array(
1070 'type' => true,
1071 'class' => true,
1072 'aria-label' => true,
1073 'hidden' => true,
1074 );
1075 $allowed_html['span'] = array_merge(
1076 (array) ( $allowed_html['span'] ?? array() ),
1077 array(
1078 'class' => true,
1079 'role' => true,
1080 'aria-live' => true,
1081 'aria-hidden' => true,
1082 )
1083 );
1084
1085 $html = str_replace( '<ul class="nav-tab-wrapper">', '<ul class="nav-tab-wrapper">' . $search_box, $html );
1086
1087 echo wp_kses( $html, $allowed_html );
1088 }
1089
1090 /**
1091 * Show the section settings forms
1092 *
1093 * This public function displays every sections in a different form
1094 */
1095 public function show_form() {
1096 ?>
1097
1098 <form method="post" action="options.php" id="<?php echo esc_attr( "{$this->prefix}-settings-form" ); ?>">
1099
1100 <?php settings_fields( $this->settings_key ); ?>
1101
1102 <?php foreach ( $this->settings_sections as $tab_id => $tab_name ) : ?>
1103
1104 <div id="<?php echo esc_attr( $tab_id ); ?>">
1105 <h2 class="wz-section-title" tabindex="-1"><?php echo esc_html( $tab_name ); ?></h2>
1106 <table class="form-table">
1107 <?php
1108 do_settings_fields( $this->prefix . '_settings_' . $tab_id, $this->prefix . '_settings_' . $tab_id );
1109 ?>
1110 </table>
1111 <p>
1112 <?php
1113 // Default submit button.
1114 submit_button(
1115 $this->translation_strings['save_changes'],
1116 'primary',
1117 'submit',
1118 false
1119 );
1120
1121 echo '&nbsp;&nbsp;';
1122
1123 // Reset button.
1124 $confirm = esc_js( $this->translation_strings['reset_button_confirm'] );
1125 submit_button(
1126 $this->translation_strings['reset_settings'],
1127 'secondary',
1128 'settings_reset',
1129 false,
1130 array(
1131 'onclick' => "return confirm('{$confirm}');",
1132 )
1133 );
1134
1135 echo '&nbsp;&nbsp;';
1136
1137 /**
1138 * Action to add more buttons in each tab.
1139 *
1140 * @param string $tab_id Tab ID.
1141 * @param string $tab_name Tab name.
1142 * @param array $settings_sections Settings sections.
1143 */
1144 do_action( $this->prefix . '_settings_form_buttons', $tab_id, $tab_name, $this->settings_sections ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound
1145 ?>
1146 </p>
1147 <p class="wz-modified-legend"><span class="wz-modified-dot"></span><?php echo esc_html( $this->translation_strings['modified_legend'] ?? 'Setting modified from its default value' ); ?></p>
1148 </div><!-- /#tab_id-->
1149
1150 <?php endforeach; ?>
1151
1152 </form>
1153
1154 <?php
1155 }
1156
1157 /**
1158 * Add rating links to the admin dashboard
1159 *
1160 * @param string $footer_text The existing footer text.
1161 * @return string Updated Footer text
1162 */
1163 public function admin_footer_text( $footer_text ) {
1164
1165 if ( ! empty( $this->admin_footer_text ) && get_current_screen()->id === $this->settings_page ) {
1166
1167 $text = $this->admin_footer_text;
1168
1169 return str_replace( '</span>', '', $footer_text ) . ' | ' . $text . '</span>';
1170 } else {
1171 return $footer_text;
1172 }
1173 }
1174
1175 /**
1176 * Function to add the contextual help in the settings page.
1177 */
1178 public function settings_help() {
1179 $screen = get_current_screen();
1180
1181 if ( $screen->id !== $this->settings_page ) {
1182 return;
1183 }
1184
1185 $screen->set_help_sidebar( $this->help_sidebar );
1186
1187 foreach ( $this->help_tabs as $tab ) {
1188 $screen->add_help_tab( $tab );
1189 }
1190 }
1191
1192 /**
1193 * Parse field arguments with defaults.
1194 *
1195 * @param array $field Field arguments.
1196 * @param string $section Section name.
1197 *
1198 * @return array Parsed field arguments.
1199 */
1200 public static function parse_field_args( $field, $section = '' ) {
1201 $defaults = array(
1202 'id' => null,
1203 'name' => '',
1204 'desc' => '',
1205 'type' => 'text',
1206 'size' => null,
1207 'options' => '',
1208 'default' => '',
1209 'min' => 0,
1210 'max' => 999999,
1211 'step' => 1,
1212 'field_class' => '',
1213 'field_attributes' => array(),
1214 'placeholder' => '',
1215 'readonly' => false,
1216 'required' => false,
1217 'disabled' => false,
1218 'pro' => false,
1219 'section' => $section,
1220 );
1221
1222 $field = wp_parse_args( $field, $defaults );
1223
1224 // Add required indicator to field name if the field is required.
1225 if ( ! empty( $field['required'] ) && true === $field['required'] ) {
1226 $field['name'] = sprintf( '%s <span class="required" title="%s">*</span>', $field['name'], 'Required' );
1227 }
1228
1229 return $field;
1230 }
1231
1232 /**
1233 * Get the encryption key for API key encryption/decryption.
1234 *
1235 * @param string $prefix Optional prefix for fallback key.
1236 * @return string The encryption key.
1237 */
1238 public static function get_encryption_key( $prefix = '' ) {
1239 $fallback = $prefix ? str_replace( '-', '_', $prefix ) . '_encryption_fallback' : 'settings_api_encryption_fallback';
1240 return defined( 'AUTH_SALT' ) ? AUTH_SALT : ( defined( 'SECURE_AUTH_SALT' ) ? SECURE_AUTH_SALT : hash( 'sha256', __NAMESPACE__ . $fallback ) );
1241 }
1242
1243 /**
1244 * Encrypts an API key using either OpenSSL or Sodium, if available.
1245 *
1246 * @param string $key The API key to encrypt.
1247 * @param string $prefix Optional prefix for fallback key.
1248 * @return string The encrypted API key, or the plain text key if no secure method is available.
1249 */
1250 public static function encrypt_api_key( $key, $prefix = '' ) {
1251 if ( empty( $key ) ) {
1252 return '';
1253 }
1254
1255 // Use OpenSSL if available.
1256 if ( extension_loaded( 'openssl' ) ) {
1257 $iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
1258 $iv = openssl_random_pseudo_bytes( $iv_length );
1259 $encrypted = openssl_encrypt( $key, 'aes-256-cbc', self::get_encryption_key( $prefix ), 0, $iv );
1260
1261 // Store IV + ciphertext in hex format.
1262 return 'enc:' . bin2hex( $iv . $encrypted );
1263 }
1264
1265 // Use Sodium (libsodium) if OpenSSL is unavailable.
1266 if ( extension_loaded( 'sodium' ) ) {
1267 $sodium_key = substr( hash( 'sha256', self::get_encryption_key( $prefix ), true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1268 $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
1269 $encrypted = sodium_crypto_secretbox( $key, $nonce, $sodium_key );
1270
1271 return 'enc:' . sodium_bin2hex( $nonce . $encrypted );
1272 }
1273
1274 return $key;
1275 }
1276
1277 /**
1278 * Decrypts an API key using either OpenSSL or Sodium, if available.
1279 *
1280 * @param string $encrypted_key The encrypted API key to decrypt.
1281 * @param string $prefix Optional prefix for fallback key.
1282 * @return string The decrypted API key, or the encrypted key if no secure method is available.
1283 */
1284 public static function decrypt_api_key( $encrypted_key, $prefix = '' ) {
1285 if ( empty( $encrypted_key ) ) {
1286 return '';
1287 }
1288
1289 // If the key doesn't start with 'enc:', it's not encrypted.
1290 if ( strpos( $encrypted_key, 'enc:' ) !== 0 ) {
1291 return $encrypted_key;
1292 }
1293
1294 // Remove the 'enc:' prefix.
1295 $encrypted_key = substr( $encrypted_key, 4 );
1296
1297 // Try OpenSSL decryption.
1298 if ( extension_loaded( 'openssl' ) ) {
1299 $data = hex2bin( $encrypted_key );
1300 if ( false === $data ) {
1301 return '';
1302 }
1303
1304 $iv_length = openssl_cipher_iv_length( 'aes-256-cbc' );
1305 $iv = mb_substr( $data, 0, $iv_length, '8bit' );
1306 $ciphertext = mb_substr( $data, $iv_length, null, '8bit' );
1307
1308 $decrypted = openssl_decrypt( $ciphertext, 'aes-256-cbc', self::get_encryption_key( $prefix ), 0, $iv );
1309 return false === $decrypted ? '' : $decrypted;
1310 }
1311
1312 // Try Sodium (libsodium) decryption.
1313 if ( extension_loaded( 'sodium' ) ) {
1314 $sodium_key = substr( hash( 'sha256', self::get_encryption_key( $prefix ), true ), 0, SODIUM_CRYPTO_SECRETBOX_KEYBYTES );
1315 $decoded = sodium_hex2bin( $encrypted_key );
1316
1317 if ( ! $decoded ) {
1318 return '';
1319 }
1320
1321 $nonce = mb_substr( $decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit' );
1322 $ciphertext = mb_substr( $decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit' );
1323 $decrypted = sodium_crypto_secretbox_open( $ciphertext, $nonce, $sodium_key );
1324
1325 return false === $decrypted ? '' : $decrypted;
1326 }
1327
1328 return $encrypted_key;
1329 }
1330 }
1331