PluginProbe
Booking Calendar / 11.6
Booking Calendar v11.6
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 / _front_end / class-fe-booking-context.php

class-fe-booking-context.php in Booking Calendar 11.6, at includes/_front_end/class-fe-booking-context.php

257 lines 10.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Signed context for Classic Booking Calendar shortcode AJAX requests.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Normalize one YYYY-MM-DD value and reject impossible calendar dates.
14 *
15 * @param mixed $date_value Candidate date value.
16 *
17 * @return string Valid normalized date or an empty string.
18 */
19 function wpbc_classic_booking_context_normalize_date( $date_value ) {
20 $date_value = sanitize_text_field( (string) $date_value );
21 if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_value ) ) {
22 return '';
23 }
24
25 $date_object = DateTimeImmutable::createFromFormat( '!Y-m-d', $date_value, wp_timezone() );
26 if ( false === $date_object || $date_object->format( 'Y-m-d' ) !== $date_value ) {
27 return '';
28 }
29
30 return $date_value;
31 }
32
33 /**
34 * Determine whether a Classic shortcode range intentionally starts in the past.
35 *
36 * The historical Booking Calendar contract treats a past calendar_dates_start
37 * value as site-author permission to submit dates from that visible range.
38 *
39 * @param mixed $calendar_dates_start Inclusive shortcode start date.
40 * @param string $today_ymd Optional YYYY-MM-DD comparison date for deterministic callers and tests.
41 *
42 * @return bool True when the valid range start is earlier than today.
43 */
44 function wpbc_classic_booking_context_should_allow_past( $calendar_dates_start, $today_ymd = '' ) {
45 $calendar_dates_start = wpbc_classic_booking_context_normalize_date( $calendar_dates_start );
46 $today_ymd = wpbc_classic_booking_context_normalize_date( $today_ymd );
47
48 if ( '' === $today_ymd ) {
49 $today_ymd = current_time( 'Y-m-d' );
50 }
51
52 return '' !== $calendar_dates_start && $calendar_dates_start < $today_ymd;
53 }
54
55 /**
56 * Normalize the legacy default Booking Form representation.
57 *
58 * The standard form does not render a booking_form_type hidden field, so its
59 * frontend submission uses an empty string even though the shortcode resolver
60 * represents the same form as "standard".
61 *
62 * @param mixed $custom_form Candidate Booking Form slug.
63 *
64 * @return string Sanitized Booking Form slug, using "standard" for an omitted value.
65 */
66 function wpbc_classic_booking_context_normalize_form( $custom_form ) {
67 $custom_form = sanitize_text_field( (string) $custom_form );
68
69 return '' === $custom_form ? 'standard' : $custom_form;
70 }
71
72 /**
73 * Normalize Classic shortcode context before it is signed or consumed.
74 *
75 * @param mixed $context Raw context values.
76 *
77 * @return array<string,mixed> Stable context contract.
78 */
79 function wpbc_classic_booking_context_normalize( $context ) {
80 $context = is_array( $context ) ? $context : array();
81 $context = wp_parse_args(
82 $context,
83 array(
84 'resource_id' => 0,
85 'calendar_dates_start' => '',
86 'calendar_dates_end' => '',
87 'custom_form' => 'standard',
88 'aggregate_resource_ids' => array(),
89 'allow_past' => false,
90 )
91 );
92 $calendar_dates_start = wpbc_classic_booking_context_normalize_date( $context['calendar_dates_start'] );
93
94 $aggregate_resource_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $context['aggregate_resource_ids'] ) ) ) );
95 sort( $aggregate_resource_ids, SORT_NUMERIC );
96
97 // Derive permission from the site-authored date boundary; never trust a caller-supplied allow_past flag.
98 return array(
99 'resource_id' => absint( $context['resource_id'] ),
100 'calendar_dates_start' => $calendar_dates_start,
101 'calendar_dates_end' => wpbc_classic_booking_context_normalize_date( $context['calendar_dates_end'] ),
102 'custom_form' => wpbc_classic_booking_context_normalize_form( $context['custom_form'] ),
103 'aggregate_resource_ids' => $aggregate_resource_ids,
104 'allow_past' => wpbc_classic_booking_context_should_allow_past( $calendar_dates_start ),
105 );
106 }
107
108 /**
109 * Base64-url encode a context value without padding.
110 *
111 * @param string $context_value Value to encode.
112 *
113 * @return string URL-safe encoded value.
114 */
115 function wpbc_classic_booking_context_base64url_encode( $context_value ) {
116 return rtrim( strtr( base64_encode( (string) $context_value ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
117 }
118
119 /**
120 * Decode one strict base64-url context value.
121 *
122 * @param string $encoded_value Encoded value.
123 *
124 * @return string|false Decoded value or false when malformed.
125 */
126 function wpbc_classic_booking_context_base64url_decode( $encoded_value ) {
127 $encoded_value = strtr( (string) $encoded_value, '-_', '+/' );
128 $padding = strlen( $encoded_value ) % 4;
129 if ( $padding ) {
130 $encoded_value .= str_repeat( '=', 4 - $padding );
131 }
132
133 return base64_decode( $encoded_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
134 }
135
136 /**
137 * Sign normalized Classic shortcode context for cache-safe AJAX round trips.
138 *
139 * The HMAC has no time component, so cached front-end pages remain usable until
140 * WordPress authentication salts change. No secret or raw signature key is
141 * exposed to the browser.
142 *
143 * @param mixed $context Raw or normalized context.
144 *
145 * @return string Signed opaque token, or an empty string for incomplete context.
146 */
147 function wpbc_classic_booking_context_encode( $context ) {
148 $context = wpbc_classic_booking_context_normalize( $context );
149 if (
150 0 === $context['resource_id']
151 || '' === $context['calendar_dates_start']
152 || '' === $context['calendar_dates_end']
153 || $context['calendar_dates_start'] > $context['calendar_dates_end']
154 ) {
155 return '';
156 }
157
158 $payload = wpbc_classic_booking_context_base64url_encode( wp_json_encode( $context ) );
159 $signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ), true );
160
161 return $payload . '.' . wpbc_classic_booking_context_base64url_encode( $signature );
162 }
163
164 /**
165 * Verify and decode a signed Classic shortcode context token.
166 *
167 * @param string $context_token Signed token received through AJAX.
168 *
169 * @return array<string,mixed>|WP_Error Normalized context or a safe validation error.
170 */
171 function wpbc_classic_booking_context_decode( $context_token ) {
172 $token_parts = explode( '.', (string) $context_token, 2 );
173 if ( 2 !== count( $token_parts ) ) {
174 return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) );
175 }
176
177 $expected_signature = hash_hmac( 'sha256', $token_parts[0], wp_salt( 'auth' ), true );
178 $actual_signature = wpbc_classic_booking_context_base64url_decode( $token_parts[1] );
179 if ( false === $actual_signature || ! hash_equals( $expected_signature, $actual_signature ) ) {
180 return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) );
181 }
182
183 $context_json = wpbc_classic_booking_context_base64url_decode( $token_parts[0] );
184 $context = false !== $context_json ? json_decode( $context_json, true ) : null;
185 if ( ! is_array( $context ) ) {
186 return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) );
187 }
188
189 $context = wpbc_classic_booking_context_normalize( $context );
190 if (
191 0 === $context['resource_id']
192 || '' === $context['calendar_dates_start']
193 || '' === $context['calendar_dates_end']
194 || $context['calendar_dates_start'] > $context['calendar_dates_end']
195 ) {
196 return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) );
197 }
198
199 return $context;
200 }
201
202 /**
203 * Validate a Classic AJAX request against its signed shortcode boundaries.
204 *
205 * @param string $context_token Signed Classic context token.
206 * @param mixed $resource_id Submitted primary Booking Resource ID.
207 * @param array|string $submitted_dates Submitted YYYY-MM-DD dates.
208 * @param string $custom_form Submitted Booking Form identifier.
209 * @param array|string $aggregate_resource_ids Submitted aggregate Booking Resource IDs.
210 *
211 * @return array<string,mixed>|WP_Error Verified context or a validation error.
212 */
213 function wpbc_classic_booking_context_validate_submission( $context_token, $resource_id, $submitted_dates, $custom_form = 'standard', $aggregate_resource_ids = array() ) {
214 $context = wpbc_classic_booking_context_decode( $context_token );
215 if ( is_wp_error( $context ) ) {
216 return $context;
217 }
218
219 if ( absint( $resource_id ) !== $context['resource_id'] ) {
220 return new WP_Error( 'classic_booking_context_resource_mismatch', __( 'The selected booking resource does not match this booking form. Please reload the page and try again.', 'booking' ) );
221 }
222
223 $custom_form = wpbc_classic_booking_context_normalize_form( $custom_form );
224 if ( $custom_form !== $context['custom_form'] ) {
225 return new WP_Error( 'classic_booking_context_form_mismatch', __( 'The selected Booking Form does not match this calendar. Please reload the page and try again.', 'booking' ) );
226 }
227
228 if ( is_string( $aggregate_resource_ids ) ) {
229 $aggregate_resource_ids = preg_split( '/[;,\s]+/', $aggregate_resource_ids, -1, PREG_SPLIT_NO_EMPTY );
230 }
231 $aggregate_resource_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $aggregate_resource_ids ) ) ) );
232 sort( $aggregate_resource_ids, SORT_NUMERIC );
233 if ( $aggregate_resource_ids !== $context['aggregate_resource_ids'] ) {
234 return new WP_Error( 'classic_booking_context_aggregate_mismatch', __( 'The booking resources do not match this calendar. Please reload the page and try again.', 'booking' ) );
235 }
236
237 if ( is_string( $submitted_dates ) ) {
238 $submitted_dates = preg_split( '/\s*,\s*/', $submitted_dates, -1, PREG_SPLIT_NO_EMPTY );
239 }
240 $submitted_dates = array_values( (array) $submitted_dates );
241 if ( empty( $submitted_dates ) ) {
242 return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) );
243 }
244
245 foreach ( $submitted_dates as $submitted_date ) {
246 $submitted_date = wpbc_classic_booking_context_normalize_date( $submitted_date );
247 if ( '' === $submitted_date ) {
248 return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) );
249 }
250 if ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] ) {
251 return new WP_Error( 'classic_booking_context_date_outside_range', __( 'The selected booking date is outside this calendar range. Please select another date.', 'booking' ) );
252 }
253 }
254
255 return $context;
256 }
257