PluginProbe
Booking Calendar / 11.8.3
Booking Calendar v11.8.3
11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 10.11 10.11.2 All 203 releases
booking / includes / save-load-option / save-load-option.php

save-load-option.php in Booking Calendar 11.8.3, at includes/save-load-option/save-load-option.php

551 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * General Option Loader/Saver (AJAX)
4 *
5 * - Save complex structures by posting RAW JSON (string) -> json_decode() -> array stored via update_option().
6 * - Save simple scalars (e.g., "On"/"Off") as-is.
7 * - Load returns stored value (array/scalar).
8 * - Enqueues small JS/CSS that provide generic save/load helpers with busy (spinner) UI.
9 *
10 * file: ../includes/save-load-option/save-load-option.php
11 *
12 * Data attributes on clickable elements:
13 * Save:
14 * data-wpbc-u-save-name — option key (required)
15 * SAVE requests use a fixed, server-generated nonce localized with this module.
16 * data-wpbc-u-save-value — RAW scalar to save (optional)
17 * data-wpbc-u-save-value-json— JSON string to save (optional)
18 * data-wpbc-u-save-fields — CSV of selectors; values serialized with jQuery.param (optional)
19 * data-wpbc-u-busy-text — custom text during AJAX (optional)
20 * data-wpbc-u-save-callback — window function name to call on success (optional)
21 *
22 * Load:
23 * data-wpbc-u-load-name — option key (required)
24 * data-wpbc-u-busy-text — custom text during AJAX (optional)
25 * data-wpbc-u-load-callback — window function name to receive loaded value (optional)
26 *
27 * JS Events:
28 * jQuery(document)
29 * .on('wpbc:option:beforeSave', function (e, $el, payload) {})
30 * .on('wpbc:option:afterSave', function (e, response) {})
31 * .on('wpbc:option:beforeLoad', function (e, $el, name) {})
32 * .on('wpbc:option:afterLoad', function (e, response) {})
33 *
34 * @package Booking Calendar
35 * @author wpdevelop
36 * @since 11.0.0
37 * @version 1.0.2
38 */
39
40 if ( ! defined( 'ABSPATH' ) ) {
41 exit;
42 }
43
44 class wpbc_option_saver_loader {
45
46 private static $ajax_action_save = 'wpbc_ajax_option_save';
47 private static $ajax_action_load = 'wpbc_ajax_option_load';
48 private static $nonce_action_save = 'wpbc_option_save';
49 private static $nonce_action_load = 'wpbc_option_load';
50 private static $option_prefix = '';
51 private static $asset_version = '1.0.2';
52 private static $save_policies = array();
53
54 public static function init() {
55 add_action( 'init', array( __CLASS__, 'register_ajax_handlers' ) );
56 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
57 }
58
59 /**
60 * Register an option-specific save and load policy.
61 *
62 * Registration is the endpoint allowlist. Unregistered option names are
63 * rejected even when the current user has the configured capability.
64 *
65 * Supported policy keys:
66 * - can_save callable(): bool
67 * - permission_message string
68 * - normalize_raw callable( mixed $data_raw, string $data_name ): mixed
69 * - force_mode string
70 * - allowed_keys array|callable(): array
71 * - normalize_item callable( string $option_key, mixed $value, string $data_name ): mixed
72 *
73 * @param string $option_name Option name from data_name.
74 * @param array $policy Policy definition.
75 *
76 * @return void
77 */
78 public static function register_option_policy( $option_name, $policy ) {
79
80 $option_name = sanitize_key( (string) $option_name );
81
82 if ( empty( $option_name ) || ! is_array( $policy ) ) {
83 return;
84 }
85
86 self::$save_policies[ $option_name ] = $policy;
87 }
88
89 /**
90 * Get a registered option save policy.
91 *
92 * @param string $option_name Option name from data_name.
93 *
94 * @return array
95 */
96 private static function get_option_policy( $option_name ) {
97
98 $option_name = sanitize_key( (string) $option_name );
99
100 return ( isset( self::$save_policies[ $option_name ] ) && is_array( self::$save_policies[ $option_name ] ) )
101 ? self::$save_policies[ $option_name ]
102 : array();
103 }
104
105 /**
106 * Check whether an option name was explicitly registered for this endpoint.
107 *
108 * Registration is the writable and readable option allowlist. Sanitizing an
109 * arbitrary WordPress option name does not make that option safe to expose.
110 *
111 * @param string $option_name Option name from data_name.
112 *
113 * @return bool True when a policy was explicitly registered.
114 */
115 private static function has_option_policy( $option_name ) {
116
117 $option_name = sanitize_key( (string) $option_name );
118
119 return '' !== $option_name
120 && isset( self::$save_policies[ $option_name ] )
121 && is_array( self::$save_policies[ $option_name ] );
122 }
123
124 /**
125 * Get allowed option keys from a policy.
126 *
127 * @param array $policy Policy definition.
128 *
129 * @return array
130 */
131 private static function get_policy_allowed_keys( $policy ) {
132
133 if ( empty( $policy['allowed_keys'] ) ) {
134 return array();
135 }
136
137 $allowed_keys = is_callable( $policy['allowed_keys'] )
138 ? call_user_func( $policy['allowed_keys'] )
139 : $policy['allowed_keys'];
140
141 if ( ! is_array( $allowed_keys ) ) {
142 return array();
143 }
144
145 return array_values(
146 array_filter(
147 array_map(
148 'sanitize_key',
149 array_map( 'strval', $allowed_keys )
150 )
151 )
152 );
153 }
154
155 /**
156 * Register AJAX handlers (logged-in admin).
157 *
158 * @return void
159 */
160 public static function register_ajax_handlers() {
161 add_action( 'wp_ajax_' . self::$ajax_action_save, array( __CLASS__, 'handle_ajax_save' ) );
162 add_action( 'wp_ajax_' . self::$ajax_action_load, array( __CLASS__, 'handle_ajax_load' ) );
163 }
164
165 /**
166 * Enqueue JS/CSS for admin pages.
167 *
168 * @return void
169 */
170 public static function enqueue_assets() {
171
172 // Optional screen check.
173 if ( function_exists( 'get_current_screen' ) ) {
174 $screen = get_current_screen();
175 $ok = apply_filters( 'wpbc_option_saver_loader_enqueue', true, $screen );
176 if ( ! $ok ) {
177 return;
178 }
179 }
180
181 $base_url = plugins_url( '', defined( 'WPBC_FILE' ) ? WPBC_FILE : __FILE__ );
182 $js_url = $base_url . '/includes/save-load-option/_out/save-load-option.js';
183 $css_url = $base_url . '/includes/save-load-option/_out/save-load-option.css';
184
185 wp_register_style( 'wpbc-save-load-option', $css_url, array(), self::$asset_version );
186 wp_enqueue_style( 'wpbc-save-load-option' );
187
188 wp_register_script( 'wpbc-save-load-option', $js_url, array( 'jquery' ), self::$asset_version, true );
189 wp_enqueue_script( 'wpbc-save-load-option' );
190
191 wp_localize_script(
192 'wpbc-save-load-option',
193 'wpbc_option_saver_loader_config',
194 array(
195 'ajax_url' => admin_url( 'admin-ajax.php' ),
196 'action_save' => self::$ajax_action_save,
197 'action_load' => self::$ajax_action_load,
198 'save_nonce' => wp_create_nonce( self::$nonce_action_save ),
199 'load_nonce' => wp_create_nonce( self::$nonce_action_load ),
200 )
201 );
202 }
203
204 /**
205 * AJAX: Save option.
206 *
207 * Expected POST:
208 * - data_name string Option key.
209 * - data_value string RAW scalar | query-string | JSON string.
210 * - nonce string Nonce for the fixed wpbc_option_save action.
211 *
212 * @return void
213 */
214 public static function handle_ajax_save() {
215
216 $capability = apply_filters( 'wpbc_option_saver_loader_cap_save', ( function_exists( 'wpbc_bfb_get_manage_cap' ) ) ? wpbc_bfb_get_manage_cap() : 'manage_options' );
217 if ( ! current_user_can( $capability ) ) {
218 wp_send_json_error( array( 'message' => __( 'You do not have permission to save settings.', 'booking' ) ) );
219 }
220
221 $data_name = isset( $_POST['data_name'] ) ? sanitize_key( wp_unslash( $_POST['data_name'] ) ) : '';
222 /* phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */
223 $data_raw = isset( $_POST['data_value'] ) ? wp_unslash( $_POST['data_value'] ) : '';
224 $nonce_value = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
225
226 if ( ! wp_verify_nonce( $nonce_value, self::$nonce_action_save ) ) {
227 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'booking' ) ) );
228 }
229
230 if ( empty( $data_name ) ) {
231 wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) );
232 }
233
234 if ( ! self::has_option_policy( $data_name ) ) {
235 wp_send_json_error( array( 'message' => __( 'This option cannot be saved by this request.', 'booking' ) ) );
236 }
237
238 $save_policy = self::get_option_policy( $data_name );
239
240 if ( ! empty( $save_policy['can_save'] ) && is_callable( $save_policy['can_save'] ) && ! call_user_func( $save_policy['can_save'], $data_name ) ) {
241 $permission_message = ( ! empty( $save_policy['permission_message'] ) && is_string( $save_policy['permission_message'] ) )
242 ? $save_policy['permission_message']
243 : __( 'You do not have permission to save this option.', 'booking' );
244 wp_send_json_error( array( 'message' => $permission_message ) );
245 }
246
247 if ( ! empty( $save_policy['normalize_raw'] ) && is_callable( $save_policy['normalize_raw'] ) ) {
248 $data_raw = call_user_func( $save_policy['normalize_raw'], $data_raw, $data_name );
249 }
250
251 $data_mode = ( ! empty( $save_policy['force_mode'] ) && 'split' === sanitize_key( (string) $save_policy['force_mode'] ) ) ? 'split' : '';
252
253 $policy_allowed_keys = self::get_policy_allowed_keys( $save_policy );
254 if ( 'split' === $data_mode && empty( $policy_allowed_keys ) ) {
255 wp_send_json_error( array( 'message' => __( 'This option does not define any writable fields.', 'booking' ) ) );
256 }
257
258 $value_to_store = self::normalize_incoming_value( $data_raw );
259
260 // Split mode: JSON object => multiple options saved separately.
261 if ( 'split' === $data_mode ) {
262
263 if ( ! is_array( $value_to_store ) ) {
264 wp_send_json_error( array( 'message' => __( 'Invalid option data.', 'booking' ) ) );
265 }
266
267 $allowed_keys = array_fill_keys( $policy_allowed_keys, true );
268 $saved = array();
269
270 foreach ( $value_to_store as $k => $v ) {
271
272 if ( ! is_scalar( $k ) ) {
273 continue;
274 }
275
276 $opt_key = sanitize_key( (string) $k );
277 if ( '' === $opt_key ) {
278 continue;
279 }
280
281 if ( ! isset( $allowed_keys[ $opt_key ] ) ) {
282 continue;
283 }
284
285 // Values: allow scalar or arrays (already sanitized by normalize_incoming_value()).
286 $opt_val = $v;
287 if ( is_scalar( $opt_val ) ) {
288 $opt_val = sanitize_text_field( (string) $opt_val );
289 } elseif ( is_array( $opt_val ) ) {
290 $opt_val = self::sanitize_mixed_value( $opt_val );
291 } else {
292 $opt_val = '';
293 }
294
295 if ( ! empty( $save_policy['normalize_item'] ) && is_callable( $save_policy['normalize_item'] ) ) {
296 $opt_val = call_user_func( $save_policy['normalize_item'], $opt_key, $opt_val, $data_name );
297 }
298
299 self::update_option( self::$option_prefix . $opt_key, $opt_val );
300 $saved[ $opt_key ] = $opt_val;
301 }
302
303 if ( empty( $saved ) ) {
304 wp_send_json_error( array( 'message' => __( 'Nothing to save.', 'booking' ) ) );
305 }
306
307 wp_send_json_success(
308 array(
309 'message' => __( 'Settings saved.', 'booking' ),
310 'value' => $saved,
311 'mode' => 'split',
312 )
313 );
314 }
315
316 // Default: store as a single option (scalar/array).
317 self::update_option( self::$option_prefix . $data_name, $value_to_store );
318
319 // Return stored value (useful for client callbacks / UI sync).
320 wp_send_json_success(
321 array(
322 'message' => __( 'Settings saved.', 'booking' ),
323 'value' => $value_to_store,
324 )
325 );
326 }
327
328 /**
329 * AJAX: Load option.
330 *
331 * Expected GET:
332 * - data_name string Option key.
333 * - nonce string Nonce for the fixed wpbc_option_load action.
334 *
335 * @return void
336 */
337 public static function handle_ajax_load() {
338
339 $capability = apply_filters( 'wpbc_option_saver_loader_cap_load', ( function_exists( 'wpbc_bfb_get_manage_cap' ) ) ? wpbc_bfb_get_manage_cap() : 'manage_options' );
340 if ( ! current_user_can( $capability ) ) {
341 wp_send_json_error( array( 'message' => __( 'You do not have permission to load settings.', 'booking' ) ) );
342 }
343
344 $nonce_value = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : '';
345 if ( ! wp_verify_nonce( $nonce_value, self::$nonce_action_load ) ) {
346 wp_send_json_error( array( 'message' => __( 'Invalid nonce.', 'booking' ) ) );
347 }
348
349 $data_name = isset( $_GET['data_name'] ) ? sanitize_key( wp_unslash( $_GET['data_name'] ) ) : '';
350 if ( empty( $data_name ) ) {
351 wp_send_json_error( array( 'message' => __( 'Missing data name.', 'booking' ) ) );
352 }
353
354 if ( ! self::has_option_policy( $data_name ) ) {
355 wp_send_json_error( array( 'message' => __( 'This option cannot be loaded by this request.', 'booking' ) ) );
356 }
357
358 $load_policy = self::get_option_policy( $data_name );
359 if ( ! empty( $load_policy['can_save'] ) && is_callable( $load_policy['can_save'] ) && ! call_user_func( $load_policy['can_save'], $data_name ) ) {
360 wp_send_json_error( array( 'message' => __( 'You do not have permission to load this option.', 'booking' ) ) );
361 }
362
363 $option_key = self::$option_prefix . $data_name;
364 $value = self::get_option( $option_key, array() );
365
366 wp_send_json_success( array( 'value' => $value ) );
367 }
368
369 /**
370 * Normalize payload: prefer JSON -> array; fallback to query-string -> array; else scalar string.
371 *
372 * @param string $data_raw Raw input.
373 * @return mixed
374 */
375 private static function normalize_incoming_value( $data_raw ) {
376
377 if ( ! is_string( $data_raw ) || '' === $data_raw ) {
378 return '';
379 }
380
381 $maybe_json = trim( $data_raw );
382
383 // JSON path.
384 if (
385 0 === strpos( $maybe_json, '{' ) || 0 === strpos( $maybe_json, '[' ) ||
386 'null' === strtolower( $maybe_json ) || 'true' === strtolower( $maybe_json ) ||
387 'false' === strtolower( $maybe_json ) || is_numeric( $maybe_json )
388 ) {
389 $decoded = json_decode( $maybe_json, true );
390 if ( null !== $decoded && JSON_ERROR_NONE === json_last_error() ) {
391 return self::sanitize_mixed_value( $decoded );
392 }
393 }
394
395 // Query-string path.
396 if ( false !== strpos( $data_raw, '=' ) || false !== strpos( $data_raw, '&' ) || false !== strpos( strtolower( $data_raw ), '%5b' ) ) {
397 $parsed = array();
398 parse_str( $data_raw, $parsed ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
399 return self::sanitize_kv_array_preserve_brackets( $parsed );
400 }
401
402 // Scalar.
403 return sanitize_text_field( $data_raw );
404 }
405
406 /**
407 * Recursively sanitize mixed values.
408 *
409 * @param mixed $value Mixed value.
410 * @return mixed
411 */
412 private static function sanitize_mixed_value( $value ) {
413
414 if ( is_array( $value ) ) {
415 $out = array();
416 foreach ( $value as $k => $v ) {
417 $kk = is_string( $k ) ? preg_replace( '/[^a-zA-Z0-9_\-\[\]]/', '', $k ) : $k;
418 $out[ $kk ] = self::sanitize_mixed_value( $v );
419 }
420 return $out;
421 }
422
423 if ( is_scalar( $value ) ) {
424 return sanitize_text_field( (string) $value );
425 }
426
427 return '';
428 }
429
430 /**
431 * Sanitize arrays parsed from query-string, preserving bracket keys.
432 *
433 * @param array $parsed_data Parsed data.
434 * @return array
435 */
436 private static function sanitize_kv_array_preserve_brackets( $parsed_data ) {
437
438 $sanitized_data = array();
439
440 if ( empty( $parsed_data ) || ! is_array( $parsed_data ) ) {
441 return $sanitized_data;
442 }
443
444 foreach ( $parsed_data as $key => $val ) {
445 $key = preg_replace( '/[^a-zA-Z0-9_\-\[\]]/', '', (string) $key );
446 if ( is_array( $val ) ) {
447 $sanitized_data[ $key ] = self::sanitize_mixed_value( $val );
448 } else {
449 $sanitized_data[ $key ] = sanitize_text_field( $val );
450 }
451 }
452
453 return $sanitized_data;
454 }
455
456 /**
457 * Update option (Booking Calendar wrapper if present).
458 *
459 * @param string $option_key Key.
460 * @param mixed $value Value.
461 * @return void
462 */
463 private static function update_option( $option_key, $value ) {
464 if ( function_exists( 'update_bk_option' ) ) {
465 update_bk_option( $option_key, $value );
466 } else {
467 update_option( $option_key, $value );
468 }
469 }
470
471 /**
472 * Get option (Booking Calendar wrapper if present).
473 *
474 * @param string $option_key Key.
475 * @param mixed $default Default.
476 * @return mixed
477 */
478 private static function get_option( $option_key, $default = false ) {
479 if ( function_exists( 'get_bk_option' ) ) {
480 $val = get_bk_option( $option_key );
481 return ( null === $val ) ? $default : $val;
482 }
483 return get_option( $option_key, $default );
484 }
485 }
486
487 require_once __DIR__ . '/option-save-policies.php';
488
489 add_action( 'plugins_loaded', array( 'wpbc_option_saver_loader', 'init' ) );
490
491
492 /**
493 * == Usage examples ==
494 *
495 * 1) Save RAW scalar (On/Off).
496 *
497
498 <?php
499 $opt_name = 'booking_timeslot_picker';
500 ?>
501 <a href="javascript:void(0);"
502 class="button button-secondary"
503 onclick="(function(btn){var $=jQuery, $chk=$('.js-toggle-timeslot-picker').first(); $(btn).data('wpbc-u-save-value',$chk.is(':checked')?'On':'Off'); wpbc_save_option_from_element(btn);})(this)"
504 data-wpbc-u-save-name="<?php echo esc_attr( $opt_name ); ?>"
505 data-wpbc-u-busy-text="<?php esc_attr_e( 'Saving…', 'booking' ); ?>">
506 <?php esc_html_e( 'Save Toggle', 'booking' ); ?>
507 </a>
508
509 *
510 * 2) Save complex structure (RAW JSON).
511 *
512 * Register an exact server-side policy for wpbc_bfb_form_structure before
513 * rendering this control. Client attributes never register writable options.
514 *
515
516 <?php
517 $opt_name = 'wpbc_bfb_form_structure';
518 ?>
519 <a href="javascript:void(0);"
520 class="button button-primary"
521 onclick="(function(btn){var s=window.wpbc_bfb && window.wpbc_bfb.get_structure ? window.wpbc_bfb.get_structure() : []; jQuery(btn).data('wpbc-u-save-value-json', JSON.stringify(s)); wpbc_save_option_from_element(btn);})(this)"
522 data-wpbc-u-save-name="<?php echo esc_attr( $opt_name ); ?>"
523 data-wpbc-u-busy-text="<?php esc_attr_e( 'Saving…', 'booking' ); ?>">
524 <?php esc_html_e( 'Save Form Structure', 'booking' ); ?>
525 </a>
526
527 *
528 * 3) Load option and apply
529 *
530
531 <a href="javascript:void(0);"
532 class="button"
533 onclick="wpbc_load_option_from_element(this)"
534 data-wpbc-u-load-name="wpbc_bfb_form_structure"
535 data-wpbc-u-load-callback="wpbc_bfb__on_structure_loaded"
536 data-wpbc-u-busy-text="<?php esc_attr_e( 'Loading…', 'booking' ); ?>">
537 <?php esc_html_e( 'Load Form Structure', 'booking' ); ?>
538 </a>
539 <script>
540 function wpbc_bfb__on_structure_loaded(val){
541 try {
542 if ( typeof val === 'string' ) { val = JSON.parse(val); }
543 if ( window.wpbc_bfb && typeof window.wpbc_bfb.load_saved_structure === 'function' ) {
544 window.wpbc_bfb.load_saved_structure( val || [] );
545 }
546 } catch(e){ console.error(e); }
547 }
548 </script>
549 *
550 */
551