PluginProbe
Booktics – Appointment Booking Calendar for Service Businesses / 1.0.29
Booktics – Appointment Booking Calendar for Service Businesses v1.0.29
1.0.29 1.0.28 1.0.27 1.0.26 1.0.25 1.0.24 1.0.23 1.0.22 1.0.21 1.0.20 1.0.19 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 1.0.18 1.0.2 1.0.3 All 31 releases
booktics / core / cart / cart-session.php

cart-session.php in Booktics – Appointment Booking Calendar for Service Businesses 1.0.29, at core/cart/cart-session.php

357 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Booktics\Cart;
4
5 /**
6 * Class Cart_Session
7 * Handles cart session management for the Booktics plugin
8 *
9 * Every session this class opens is closed again before the method returns so
10 * the PHP session file lock is never held for the rest of the request. A held
11 * lock blocks other requests from the same browser, such as WordPress Site
12 * Health loopback requests to the REST API. Sessions started by other code are
13 * left open, because closing them would discard that code's later writes.
14 *
15 * @package Booktics\Cart
16 */
17 class Cart_Session {
18
19 /**
20 * Session key used to store cart data in a PHP session
21 *
22 * @var string
23 */
24 const SESSION_KEY = 'booktics_cart';
25
26 /**
27 * Start a PHP session when one is not running yet.
28 *
29 * Sessions are started lazily instead of on every request so that a session
30 * is never opened after output has already been flushed, which triggers a
31 * "Session cannot be started after headers have already been sent" warning.
32 *
33 * @return bool True when $_SESSION is usable.
34 */
35 private static function maybe_start_session(): bool {
36 if ( PHP_SESSION_ACTIVE === session_status() ) {
37 return true;
38 }
39
40 if ( PHP_SESSION_DISABLED === session_status() ) {
41 return false;
42 }
43
44 if ( headers_sent() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {
45 return false;
46 }
47
48 return session_start(
49 array(
50 'cookie_httponly' => true,
51 'cookie_secure' => is_ssl(),
52 'cookie_samesite' => 'Lax',
53 'use_strict_mode' => true,
54 )
55 );
56 }
57
58 /**
59 * Open the session, run a callback against it and release it again.
60 *
61 * The session is closed only when this call opened it, so a session owned by
62 * another plugin stays open for its owner.
63 *
64 * @param callable $callback Receives nothing and returns the callback result.
65 *
66 * @return array{0: bool, 1: mixed} Whether the session was usable, and the callback result.
67 */
68 private static function with_session( callable $callback ): array {
69 $should_close = PHP_SESSION_ACTIVE !== session_status();
70
71 if ( ! self::maybe_start_session() ) {
72 return array( false, null );
73 }
74
75 try {
76 return array( true, $callback() );
77 } finally {
78 if ( $should_close ) {
79 session_write_close();
80 }
81 }
82 }
83
84 /**
85 * Read the stored cart from the open session.
86 *
87 * Must only be called while the session is open.
88 *
89 * @return array{items: array, total: float, coupon: string}
90 */
91 private static function read_session_cart(): array {
92 return self::to_cart( self::read_raw_session_cart() );
93 }
94
95 /**
96 * Read the unsanitized cart from the open session.
97 *
98 * Must only be called while the session is open.
99 *
100 * @return mixed Raw session value, or null when nothing is stored.
101 */
102 private static function read_raw_session_cart() {
103 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_session_data() in to_cart(). Session data is never slashed, so it is not unslashed.
104 return ! empty( $_SESSION[ self::SESSION_KEY ] ) ? $_SESSION[ self::SESSION_KEY ] : null;
105 }
106
107 /**
108 * Turn raw session data into a sanitized cart.
109 *
110 * @param mixed $raw_data Raw session value.
111 *
112 * @return array{items: array, total: float, coupon: string}
113 */
114 private static function to_cart( $raw_data ): array {
115 $session_data = self::sanitize_session_data( $raw_data );
116
117 return is_array( $session_data ) ? $session_data : self::empty_cart();
118 }
119
120 /**
121 * Read, modify and write the cart while holding the session lock once.
122 *
123 * Holding the lock across the whole read-modify-write stops parallel
124 * requests from the same browser from overwriting each other's changes.
125 *
126 * @param callable $callback Receives the current cart and returns the new cart.
127 *
128 * @return array|null The stored cart, or null when the session is unavailable.
129 */
130 private static function mutate( callable $callback ): ?array {
131 list( , $cart ) = self::with_session(
132 function () use ( $callback ) {
133 $cart = $callback( self::read_session_cart() );
134 $_SESSION[ self::SESSION_KEY ] = $cart;
135
136 return $cart;
137 }
138 );
139
140 return $cart;
141 }
142
143 /**
144 * Get the amount a cart item adds to the cart total.
145 *
146 * Session data always carries a subtotal, which is 0 when none was stored,
147 * so a zero subtotal falls back to the item price.
148 *
149 * @param array $item Cart item.
150 *
151 * @return float
152 */
153 private static function item_amount( array $item ): float {
154 $subtotal = floatval( $item['subtotal'] ?? 0 );
155
156 return $subtotal > 0 ? $subtotal : floatval( $item['price'] ?? 0 );
157 }
158
159 /**
160 * Sum the amounts of the given cart items.
161 *
162 * @param array $items Cart items.
163 *
164 * @return float
165 */
166 private static function calculate_total( array $items ): float {
167 return array_reduce(
168 $items,
169 function ( $total, $item ) {
170 return $total + self::item_amount( $item );
171 },
172 0.00
173 );
174 }
175
176 /**
177 * Get an empty cart structure
178 *
179 * @return array{items: array, total: float, coupon: string}
180 */
181 private static function empty_cart(): array {
182 return array(
183 'items' => array(),
184 'total' => 0.00,
185 'coupon' => '',
186 );
187 }
188
189 /**
190 * Get cart data from session
191 *
192 * @return array{items: array, total: float}
193 */
194 public static function get() {
195 // Sanitize after the lock is released, since sanitization filters may be slow.
196 list( $available, $raw_data ) = self::with_session(
197 function () {
198 return self::read_raw_session_cart();
199 }
200 );
201
202 return $available ? self::to_cart( $raw_data ) : self::empty_cart();
203 }
204
205 /**
206 * Sanitize raw cart session data
207 *
208 * @param mixed $data Raw data retrieved from the session.
209 *
210 * @return mixed Sanitized array or null if input is not an array.
211 */
212 private static function sanitize_session_data( $data ) {
213 if ( ! is_array( $data ) ) {
214 return null;
215 }
216
217 $sanitized_items = array();
218
219 if ( ! empty( $data['items'] ) && is_array( $data['items'] ) ) {
220 foreach ( $data['items'] as $item ) {
221 if ( ! is_array( $item ) ) {
222 continue;
223 }
224
225 $sanitized_item = array(
226 'uuid' => sanitize_text_field( $item['uuid'] ?? '' ),
227 'service_id' => absint( $item['service_id'] ?? 0 ),
228 'team_member_id' => absint( $item['team_member_id'] ?? 0 ),
229 'date' => sanitize_text_field( $item['date'] ?? '' ),
230 'start_time' => sanitize_text_field( $item['start_time'] ?? '' ),
231 'end_time' => sanitize_text_field( $item['end_time'] ?? '' ),
232 'price' => floatval( $item['price'] ?? 0 ),
233 'subtotal' => floatval( $item['subtotal'] ?? 0 ),
234 'total' => floatval( $item['total'] ?? 0 ),
235 );
236
237 $sanitized_item = apply_filters( 'booktics_pro_sanitize_cart_item', $sanitized_item, $item );
238
239 $sanitized_items[] = $sanitized_item;
240 }
241 }
242
243 return array(
244 'items' => $sanitized_items,
245 'total' => floatval( $data['total'] ?? 0.00 ),
246 'coupon' => sanitize_text_field( $data['coupon'] ?? '' ),
247 );
248 }
249
250 /**
251 * Set cart data in session
252 *
253 * @param array $cart Cart data to store in session
254 *
255 * @return bool True when the cart was stored.
256 */
257 public static function set( array $cart ): bool {
258 list( $available ) = self::with_session(
259 function () use ( $cart ) {
260 $_SESSION[ self::SESSION_KEY ] = $cart;
261 }
262 );
263
264 return $available;
265 }
266
267 /**
268 * Clear cart data from session
269 *
270 * @return bool True when the cart was cleared.
271 */
272 public static function clear(): bool {
273 list( $available ) = self::with_session(
274 function () {
275 unset( $_SESSION[ self::SESSION_KEY ] );
276 }
277 );
278
279 return $available;
280 }
281
282 /**
283 * Add an item to the cart session
284 *
285 * @param array $item
286 *
287 * @return array
288 */
289 public static function add_item( array $item ): array {
290 $cart = self::mutate(
291 function ( $cart ) use ( $item ) {
292 $cart['items'][] = $item;
293 $cart['total'] = floatval( $cart['total'] ?? 0 ) + self::item_amount( $item );
294
295 return apply_filters( 'booktics_pro_cart_session', $cart );
296 }
297 );
298
299 if ( null === $cart || empty( $cart['items'] ) ) {
300 return $item;
301 }
302
303 return end( $cart['items'] );
304 }
305
306 /**
307 * Delete an item from the cart session
308 *
309 * @param string $id
310 *
311 * @return void
312 */
313 public static function delete_item( string $id ) {
314 self::mutate(
315 function ( $cart ) use ( $id ) {
316 $cart['items'] = array_values(
317 array_filter(
318 $cart['items'],
319 function ( $item ) use ( $id ) {
320 return $item['uuid'] !== $id;
321 }
322 )
323 );
324 $cart['total'] = self::calculate_total( $cart['items'] );
325
326 return $cart;
327 }
328 );
329 }
330
331 /**
332 * Edit an item in the cart session
333 *
334 * @param string $uuid UUID of the item to edit
335 * @param array $new_data New data to update the item with
336 *
337 * @return array The updated cart, or an empty cart when the session is unavailable.
338 */
339 public static function edit_item( string $uuid, array $new_data ): array {
340 $cart = self::mutate(
341 function ( $cart ) use ( $uuid, $new_data ) {
342 $cart['items'] = array_map(
343 function ( $item ) use ( $uuid, $new_data ) {
344 return $item['uuid'] === $uuid ? array_merge( $item, $new_data ) : $item;
345 },
346 $cart['items']
347 );
348 $cart['total'] = self::calculate_total( $cart['items'] );
349
350 return apply_filters( 'booktics_pro_cart_session', $cart );
351 }
352 );
353
354 return $cart ?? self::empty_cart();
355 }
356 }
357