PluginProbe
Booking Calendar / 11.8
Booking Calendar v11.8
11.9 11.8.4 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 All 205 releases
booking / includes / booking_modes_v3 / class-wpbc-booking-modes-v3-switch-intent.php

class-wpbc-booking-modes-v3-switch-intent.php in Booking Calendar 11.8, at includes/booking_modes_v3/class-wpbc-booking-modes-v3-switch-intent.php

512 lines 19.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Signed same-site mode-switch destination intents for Booking Modes V3.
4 *
5 * @package Booking Calendar
6 * @since 11.8.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Create and validate a short-lived switch destination without persistent data.
15 */
16 final class WPBC_Booking_Modes_V3_Switch_Intent {
17
18 /** @var int Maximum signed-intent lifetime in seconds. */
19 const LIFETIME = 120;
20
21 /** @var int Maximum encoded intent length accepted from a request. */
22 const MAX_TOKEN_LENGTH = 4096;
23
24 /** @var int Maximum fragment length retained after a mode switch. */
25 const MAX_FRAGMENT_LENGTH = 512;
26
27 /**
28 * Build the fixed administration landing URL for a validated mode switch.
29 *
30 * The destination and fragment are hints only. They are signed together with
31 * the real user, effective owner, site, target mode, and expiry, then checked
32 * again against normally contributed source metadata on the landing request.
33 *
34 * @param string $mode_id Saved target mode ID.
35 * @param string $origin_url Browser-provided current administration URL.
36 * @param string $fragment Browser-provided URL fragment without `#`.
37 *
38 * @return string|WP_Error Fixed signed landing URL, or an intent error.
39 */
40 public static function create_landing_url( $mode_id, $origin_url, $fragment = '' ) {
41 $mode_id = is_scalar( $mode_id ) ? sanitize_key( (string) $mode_id ) : '';
42 if ( ! in_array( $mode_id, wpbc_booking_modes_get_allowed_mode_ids(), true ) ) {
43 return new WP_Error( 'wpbc_booking_modes_intent_invalid_mode', __( 'The selected Booking Calendar administration mode is not available.', 'booking' ) );
44 }
45
46 $context = wpbc_booking_modes_get_context();
47 $issued_at = time();
48 $payload = array(
49 'version' => 1,
50 'real_user_id' => absint( $context['real_user_id'] ),
51 'owner_user_id' => absint( $context['owner_user_id'] ),
52 'site_id' => self::get_current_site_id(),
53 'mode_id' => $mode_id,
54 'destination_url' => self::normalize_destination_url( $origin_url ),
55 'fragment' => self::normalize_fragment( $fragment ),
56 'issued_at' => $issued_at,
57 'expires_at' => $issued_at + self::LIFETIME,
58 );
59 $encoded_json = wp_json_encode( $payload );
60 if ( ! is_string( $encoded_json ) || '' === $encoded_json ) {
61 return new WP_Error( 'wpbc_booking_modes_intent_encode_failed', __( 'The mode destination could not be prepared.', 'booking' ) );
62 }
63
64 $token = self::base64url_encode( $encoded_json );
65 $signature = self::sign( $token );
66 $landing = add_query_arg(
67 array(
68 'page' => 'wpbc',
69 'wpbc_booking_mode_landing' => '1',
70 'wpbc_booking_mode_intent' => $token,
71 'wpbc_booking_mode_signature' => $signature,
72 ),
73 admin_url( 'admin.php' )
74 );
75
76 return wp_validate_redirect( $landing, admin_url( 'admin.php?page=wpbc' ) );
77 }
78
79 /**
80 * Validate and decode one signed intent in the current user/owner/site context.
81 *
82 * @param string $token Base64url-encoded JSON payload.
83 * @param string $signature Hexadecimal HMAC signature.
84 *
85 * @return array<string,mixed>|WP_Error Normalized intent, or a validation error.
86 */
87 public static function validate( $token, $signature ) {
88 $token = is_scalar( $token ) ? (string) $token : '';
89 $signature = is_scalar( $signature ) ? strtolower( (string) $signature ) : '';
90 if ( '' === $token || strlen( $token ) > self::MAX_TOKEN_LENGTH || 64 !== strlen( $signature ) || ! ctype_xdigit( $signature ) ) {
91 return self::get_validation_error();
92 }
93 if ( ! hash_equals( self::sign( $token ), $signature ) ) {
94 return self::get_validation_error();
95 }
96
97 $decoded_json = self::base64url_decode( $token );
98 $payload = is_string( $decoded_json ) ? json_decode( $decoded_json, true ) : null;
99 $required = array( 'version', 'real_user_id', 'owner_user_id', 'site_id', 'mode_id', 'destination_url', 'fragment', 'issued_at', 'expires_at' );
100 if ( ! is_array( $payload ) || array_keys( $payload ) !== $required ) {
101 return self::get_validation_error();
102 }
103
104 $context = wpbc_booking_modes_get_context();
105 $mode_id = is_scalar( $payload['mode_id'] ) ? sanitize_key( (string) $payload['mode_id'] ) : '';
106 $issued_at = absint( $payload['issued_at'] );
107 $expires_at = absint( $payload['expires_at'] );
108 $now = time();
109 $is_context_valid = 1 === absint( $payload['version'] )
110 && absint( $payload['real_user_id'] ) === absint( $context['real_user_id'] )
111 && absint( $payload['owner_user_id'] ) === absint( $context['owner_user_id'] )
112 && absint( $payload['site_id'] ) === self::get_current_site_id()
113 && $mode_id === wpbc_booking_modes_get_selected_mode_id()
114 && in_array( $mode_id, wpbc_booking_modes_get_allowed_mode_ids(), true )
115 && $issued_at <= ( $now + 30 )
116 && $expires_at >= $now
117 && $expires_at > $issued_at
118 && ( $expires_at - $issued_at ) <= self::LIFETIME;
119 if ( ! $is_context_valid ) {
120 return self::get_validation_error();
121 }
122
123 $destination_url = self::normalize_destination_url( $payload['destination_url'] );
124 $fragment = self::normalize_fragment( $payload['fragment'] );
125 if ( (string) $payload['destination_url'] !== $destination_url || (string) $payload['fragment'] !== $fragment ) {
126 return self::get_validation_error();
127 }
128
129 $payload['mode_id'] = $mode_id;
130 $payload['destination_url'] = $destination_url;
131 $payload['fragment'] = $fragment;
132
133 return $payload;
134 }
135
136 /**
137 * Resolve an authorized final URL from a validated intent and source tree.
138 *
139 * The original route is preferred, including `wpbc-new`. When it is absent or
140 * no longer openable, the target definition's default route and then Bookings
141 * are checked. The released redirect filter may change the result only to
142 * another eligible same-site administration route.
143 *
144 * @param array $intent Validated signed intent.
145 * @param array $source_navigation Normally contributed source navigation.
146 *
147 * @return string Eligible same-site administration URL.
148 */
149 public static function resolve_destination( $intent, $source_navigation ) {
150 $mode_id = isset( $intent['mode_id'] ) ? sanitize_key( (string) $intent['mode_id'] ) : '';
151 $source_navigation = is_array( $source_navigation ) ? $source_navigation : array();
152 $destination_url = isset( $intent['destination_url'] ) ? self::normalize_destination_url( $intent['destination_url'] ) : '';
153 $fragment = isset( $intent['fragment'] ) ? self::normalize_fragment( $intent['fragment'] ) : '';
154 $resolved_url = '';
155
156 if ( '' !== $destination_url && self::is_destination_eligible( $destination_url, $mode_id, $source_navigation ) ) {
157 $resolved_url = $destination_url;
158 }
159
160 if ( '' === $resolved_url ) {
161 $definition = WPBC_Booking_Modes_V3_Compatibility_Registry::get_instance()->get_compiler_definition( $mode_id );
162 if ( is_array( $definition ) && isset( $definition['default_route'] ) ) {
163 $default_url = self::get_route_url( $definition['default_route'] );
164 if ( self::is_destination_eligible( $default_url, $mode_id, $source_navigation ) ) {
165 $resolved_url = $default_url;
166 }
167 }
168 }
169 $bookings_url = admin_url( 'admin.php?page=wpbc&tab=vm_booking_listing' );
170 if ( '' === $resolved_url && self::is_destination_eligible( $bookings_url, $mode_id, $source_navigation ) ) {
171 $resolved_url = $bookings_url;
172 }
173 if ( '' === $resolved_url ) {
174 $resolved_url = admin_url( 'admin.php?page=wpbc' );
175 }
176
177 $resolved_url = self::append_fragment( $resolved_url, $fragment );
178 $current_page_id = wpbc_booking_modes_get_canonical_page_id_from_url( $destination_url );
179
180 /**
181 * Filter the server-selected URL used after a successful mode switch.
182 *
183 * The filtered URL is accepted only when it remains a same-site, non-mutating,
184 * source-registered destination available to the target mode.
185 *
186 * @param string $resolved_url Proposed administration URL.
187 * @param string $mode_id Target mode identifier.
188 * @param string $current_page_id Original canonical page identifier.
189 */
190 $filtered_url = apply_filters( 'wpbc_booking_modes_switch_redirect_url', $resolved_url, $mode_id, $current_page_id );
191 $filtered_url = is_scalar( $filtered_url ) ? (string) $filtered_url : '';
192 $filtered_fragment = self::normalize_fragment( wp_parse_url( $filtered_url, PHP_URL_FRAGMENT ) );
193 $filtered_base = self::normalize_destination_url( $filtered_url );
194 if ( '' !== $filtered_base && self::is_destination_eligible( $filtered_base, $mode_id, $source_navigation ) ) {
195 return self::append_fragment( $filtered_base, $filtered_fragment );
196 }
197
198 return $resolved_url;
199 }
200
201 /**
202 * Normalize a same-site, non-mutating Booking Calendar administration URL.
203 *
204 * @param mixed $candidate_url Untrusted destination candidate.
205 *
206 * @return string Normalized URL without a fragment, or an empty string.
207 */
208 public static function normalize_destination_url( $candidate_url ) {
209 $candidate_url = is_scalar( $candidate_url ) ? wp_validate_redirect( (string) $candidate_url, '' ) : '';
210 if ( '' === $candidate_url ) {
211 return '';
212 }
213
214 $admin_base = admin_url( 'admin.php' );
215 $candidate_host = strtolower( (string) wp_parse_url( $candidate_url, PHP_URL_HOST ) );
216 $admin_host = strtolower( (string) wp_parse_url( $admin_base, PHP_URL_HOST ) );
217 $candidate_path = (string) wp_parse_url( $candidate_url, PHP_URL_PATH );
218 $admin_path = (string) wp_parse_url( $admin_base, PHP_URL_PATH );
219 $candidate_port = absint( wp_parse_url( $candidate_url, PHP_URL_PORT ) );
220 $admin_port = absint( wp_parse_url( $admin_base, PHP_URL_PORT ) );
221 $candidate_scheme = strtolower( (string) wp_parse_url( $candidate_url, PHP_URL_SCHEME ) );
222 $admin_scheme = strtolower( (string) wp_parse_url( $admin_base, PHP_URL_SCHEME ) );
223 if ( $candidate_host !== $admin_host || $candidate_path !== $admin_path || $candidate_port !== $admin_port || $candidate_scheme !== $admin_scheme ) {
224 return '';
225 }
226
227 $query_string = wp_parse_url( $candidate_url, PHP_URL_QUERY );
228 $query_args = array();
229 if ( ! is_string( $query_string ) ) {
230 return '';
231 }
232 wp_parse_str( $query_string, $query_args );
233 $page_slug = isset( $query_args['page'] ) && is_scalar( $query_args['page'] ) ? sanitize_key( (string) $query_args['page'] ) : '';
234 if ( 0 !== strpos( $page_slug, 'wpbc' ) || 'wpbc-log-off' === $page_slug ) {
235 return '';
236 }
237
238 $blocked_keys = array( '_wpnonce', '_wp_http_referer', 'action', 'action2', 'nonce', 'security', 'wpbc_booking_mode_landing', 'wpbc_booking_mode_intent', 'wpbc_booking_mode_signature' );
239 foreach ( $blocked_keys as $blocked_key ) {
240 if ( array_key_exists( $blocked_key, $query_args ) ) {
241 return '';
242 }
243 }
244
245 $normalized_args = array();
246 foreach ( array_slice( $query_args, 0, 64, true ) as $query_key => $query_value ) {
247 $normalized_key = is_scalar( $query_key ) ? sanitize_key( (string) $query_key ) : '';
248 if ( '' === $normalized_key || $normalized_key !== (string) $query_key ) {
249 continue;
250 }
251 $normalized_args[ $normalized_key ] = self::normalize_query_value( $query_value );
252 }
253 $normalized_args['page'] = $page_slug;
254 foreach ( array( 'tab', 'subtab' ) as $route_key ) {
255 if ( isset( $normalized_args[ $route_key ] ) ) {
256 $normalized_args[ $route_key ] = sanitize_key( (string) $normalized_args[ $route_key ] );
257 }
258 }
259
260 $normalized_url = add_query_arg( $normalized_args, $admin_base );
261
262 return wp_validate_redirect( $normalized_url, '' );
263 }
264
265 /**
266 * Normalize an explicitly supplied browser URL fragment.
267 *
268 * @param mixed $fragment Untrusted fragment, with or without a leading `#`.
269 *
270 * @return string Safe fragment without a leading `#`.
271 */
272 public static function normalize_fragment( $fragment ) {
273 $fragment = is_scalar( $fragment ) ? ltrim( (string) $fragment, '#' ) : '';
274 $fragment = preg_replace( '/[\x00-\x20\x7F]/', '', $fragment );
275 $fragment = preg_replace( '/[^A-Za-z0-9_\-:.\/=%&?]/', '', (string) $fragment );
276
277 return substr( (string) $fragment, 0, self::MAX_FRAGMENT_LENGTH );
278 }
279
280 /**
281 * Determine whether a destination exists in source metadata and target mode.
282 *
283 * @param string $destination_url Normalized administration URL.
284 * @param string $mode_id Target mode ID.
285 * @param array $source_navigation Normally contributed navigation tree.
286 *
287 * @return bool True when the controller route remains available.
288 */
289 private static function is_destination_eligible( $destination_url, $mode_id, $source_navigation ) {
290 $route = self::get_route_from_url( $destination_url );
291 if ( empty( $route ) ) {
292 return false;
293 }
294 if ( 'wpbc-new' === $route['page'] ) {
295 $route = self::get_add_alias_route( $mode_id );
296 }
297 $route = self::resolve_default_route( $route, $source_navigation );
298 if ( empty( $route ) ) {
299 return false;
300 }
301
302 $source_index = WPBC_Booking_Modes_V3_Source_Index::get_instance();
303 $source_index->capture( $source_navigation );
304 if ( null === $source_index->get_record( $route ) ) {
305 return false;
306 }
307
308 $mode = wpbc_booking_modes_get_mode( $mode_id );
309 if ( ! is_array( $mode ) ) {
310 return false;
311 }
312 $canonical_page_id = wpbc_booking_modes_get_canonical_page_id_for_route( $route['page'], $route['tab'], $route['subtab'] );
313 if ( '' !== $canonical_page_id && isset( $mode['pages'][ $canonical_page_id ] ) ) {
314 return true;
315 }
316
317 return ! empty( $mode['preserve_unmapped_pages'] );
318 }
319
320 /**
321 * Resolve a page's default registered tab when the URL omits one.
322 *
323 * @param array $route Parsed page, tab, and subtab route.
324 * @param array $source_navigation Normally contributed source navigation.
325 *
326 * @return array<string,string> Resolved route, or an empty array.
327 */
328 private static function resolve_default_route( $route, $source_navigation ) {
329 if ( ! isset( $source_navigation[ $route['page'] ] ) || ! is_array( $source_navigation[ $route['page'] ] ) ) {
330 return array();
331 }
332 if ( '' !== $route['tab'] ) {
333 return $route;
334 }
335
336 $first_tab = '';
337 foreach ( $source_navigation[ $route['page'] ] as $tab_slug => $tab ) {
338 if ( ! is_array( $tab ) ) {
339 continue;
340 }
341 if ( '' === $first_tab ) {
342 $first_tab = sanitize_key( (string) $tab_slug );
343 }
344 if ( ! empty( $tab['default'] ) ) {
345 $route['tab'] = sanitize_key( (string) $tab_slug );
346 return $route;
347 }
348 }
349 $route['tab'] = $first_tab;
350
351 return '' !== $first_tab ? $route : array();
352 }
353
354 /**
355 * Convert the released native Add-page alias to the target mode route.
356 *
357 * @param string $mode_id Target mode ID.
358 *
359 * @return array<string,string> Existing Add Appointment or Add Booking route.
360 */
361 private static function get_add_alias_route( $mode_id ) {
362 $page_id = 'appointment' === $mode_id ? 'wpbc__add-appointment' : 'wpbc__add-booking';
363 $page = wpbc_booking_modes_get_canonical_page( $page_id );
364
365 return is_array( $page )
366 ? array( 'page' => $page['page'], 'tab' => $page['tab'], 'subtab' => $page['subtab'] )
367 : array();
368 }
369
370 /**
371 * Parse a normalized administration URL into its route identifiers.
372 *
373 * @param string $destination_url Normalized administration URL.
374 *
375 * @return array<string,string> Page, tab, and subtab, or an empty array.
376 */
377 private static function get_route_from_url( $destination_url ) {
378 $query_string = wp_parse_url( $destination_url, PHP_URL_QUERY );
379 $query_args = array();
380 if ( ! is_string( $query_string ) ) {
381 return array();
382 }
383 wp_parse_str( $query_string, $query_args );
384 $page = isset( $query_args['page'] ) && is_scalar( $query_args['page'] ) ? sanitize_key( (string) $query_args['page'] ) : '';
385 if ( '' === $page || 'wpbc-log-off' === $page ) {
386 return array();
387 }
388
389 return array(
390 'page' => $page,
391 'tab' => isset( $query_args['tab'] ) && is_scalar( $query_args['tab'] ) ? sanitize_key( (string) $query_args['tab'] ) : '',
392 'subtab' => isset( $query_args['subtab'] ) && is_scalar( $query_args['subtab'] ) ? sanitize_key( (string) $query_args['subtab'] ) : '',
393 );
394 }
395
396 /**
397 * Build an administration URL for a declaration route.
398 *
399 * @param array $route Page, tab, and optional subtab identifiers.
400 *
401 * @return string Same-site administration URL, or an empty string.
402 */
403 private static function get_route_url( $route ) {
404 if ( ! is_array( $route ) || empty( $route['page'] ) ) {
405 return '';
406 }
407 $query_args = array( 'page' => sanitize_key( (string) $route['page'] ) );
408 if ( ! empty( $route['tab'] ) ) {
409 $query_args['tab'] = sanitize_key( (string) $route['tab'] );
410 }
411 if ( ! empty( $route['subtab'] ) ) {
412 $query_args['subtab'] = sanitize_key( (string) $route['subtab'] );
413 }
414
415 return self::normalize_destination_url( add_query_arg( $query_args, admin_url( 'admin.php' ) ) );
416 }
417
418 /**
419 * Normalize a scalar or one-level array query value.
420 *
421 * @param mixed $query_value Parsed query value.
422 *
423 * @return string|array Sanitized query value.
424 */
425 private static function normalize_query_value( $query_value ) {
426 if ( is_array( $query_value ) ) {
427 $normalized = array();
428 foreach ( array_slice( $query_value, 0, 32, true ) as $value_key => $nested_value ) {
429 if ( ! is_scalar( $nested_value ) ) {
430 continue;
431 }
432 $normalized_key = is_int( $value_key ) ? $value_key : sanitize_key( (string) $value_key );
433 $normalized[ $normalized_key ] = sanitize_text_field( (string) $nested_value );
434 }
435
436 return $normalized;
437 }
438
439 return is_scalar( $query_value ) ? sanitize_text_field( (string) $query_value ) : '';
440 }
441
442 /**
443 * Append a validated fragment without changing the server route.
444 *
445 * @param string $destination_url Normalized URL without a fragment.
446 * @param string $fragment Validated fragment without `#`.
447 *
448 * @return string URL with the optional fragment.
449 */
450 private static function append_fragment( $destination_url, $fragment ) {
451 $fragment = self::normalize_fragment( $fragment );
452
453 return '' !== $fragment ? $destination_url . '#' . $fragment : $destination_url;
454 }
455
456 /**
457 * Return the current multisite-aware site identifier.
458 *
459 * @return int Current WordPress blog ID.
460 */
461 private static function get_current_site_id() {
462 return function_exists( 'get_current_blog_id' ) ? absint( get_current_blog_id() ) : 0;
463 }
464
465 /**
466 * Sign one encoded intent with the site's WordPress nonce salt.
467 *
468 * @param string $token Encoded intent token.
469 *
470 * @return string Hexadecimal SHA-256 HMAC.
471 */
472 private static function sign( $token ) {
473 return hash_hmac( 'sha256', (string) $token, wp_salt( 'nonce' ) );
474 }
475
476 /**
477 * Encode bytes using unpadded URL-safe Base64.
478 *
479 * @param string $bytes Raw bytes.
480 *
481 * @return string URL-safe encoded value.
482 */
483 private static function base64url_encode( $bytes ) {
484 return rtrim( strtr( base64_encode( $bytes ), '+/', '-_' ), '=' );
485 }
486
487 /**
488 * Decode unpadded URL-safe Base64 with strict validation.
489 *
490 * @param string $encoded Encoded token.
491 *
492 * @return string|false Decoded bytes, or false for malformed input.
493 */
494 private static function base64url_decode( $encoded ) {
495 if ( 1 === strlen( $encoded ) % 4 || ! preg_match( '/^[A-Za-z0-9_-]+$/', $encoded ) ) {
496 return false;
497 }
498 $padding = ( 4 - ( strlen( $encoded ) % 4 ) ) % 4;
499
500 return base64_decode( strtr( $encoded, '-_', '+/' ) . str_repeat( '=', $padding ), true );
501 }
502
503 /**
504 * Build the deliberately generic signed-intent validation error.
505 *
506 * @return WP_Error Invalid or expired intent error.
507 */
508 private static function get_validation_error() {
509 return new WP_Error( 'wpbc_booking_modes_intent_invalid', __( 'The mode destination expired or is no longer valid.', 'booking' ) );
510 }
511 }
512