true, 'cookie_secure' => is_ssl(), 'cookie_samesite' => 'Lax', 'use_strict_mode' => true, ) ); } /** * Open the session, run a callback against it and release it again. * * The session is closed only when this call opened it, so a session owned by * another plugin stays open for its owner. * * @param callable $callback Receives nothing and returns the callback result. * * @return array{0: bool, 1: mixed} Whether the session was usable, and the callback result. */ private static function with_session( callable $callback ): array { $should_close = PHP_SESSION_ACTIVE !== session_status(); if ( ! self::maybe_start_session() ) { return array( false, null ); } try { return array( true, $callback() ); } finally { if ( $should_close ) { session_write_close(); } } } /** * Read the stored cart from the open session. * * Must only be called while the session is open. * * @return array{items: array, total: float, coupon: string} */ private static function read_session_cart(): array { return self::to_cart( self::read_raw_session_cart() ); } /** * Read the unsanitized cart from the open session. * * Must only be called while the session is open. * * @return mixed Raw session value, or null when nothing is stored. */ private static function read_raw_session_cart() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized via sanitize_session_data() in to_cart(). Session data is never slashed, so it is not unslashed. return ! empty( $_SESSION[ self::SESSION_KEY ] ) ? $_SESSION[ self::SESSION_KEY ] : null; } /** * Turn raw session data into a sanitized cart. * * @param mixed $raw_data Raw session value. * * @return array{items: array, total: float, coupon: string} */ private static function to_cart( $raw_data ): array { $session_data = self::sanitize_session_data( $raw_data ); return is_array( $session_data ) ? $session_data : self::empty_cart(); } /** * Read, modify and write the cart while holding the session lock once. * * Holding the lock across the whole read-modify-write stops parallel * requests from the same browser from overwriting each other's changes. * * @param callable $callback Receives the current cart and returns the new cart. * * @return array|null The stored cart, or null when the session is unavailable. */ private static function mutate( callable $callback ): ?array { list( , $cart ) = self::with_session( function () use ( $callback ) { $cart = $callback( self::read_session_cart() ); $_SESSION[ self::SESSION_KEY ] = $cart; return $cart; } ); return $cart; } /** * Get the amount a cart item adds to the cart total. * * Session data always carries a subtotal, which is 0 when none was stored, * so a zero subtotal falls back to the item price. * * @param array $item Cart item. * * @return float */ private static function item_amount( array $item ): float { $subtotal = floatval( $item['subtotal'] ?? 0 ); return $subtotal > 0 ? $subtotal : floatval( $item['price'] ?? 0 ); } /** * Sum the amounts of the given cart items. * * @param array $items Cart items. * * @return float */ private static function calculate_total( array $items ): float { return array_reduce( $items, function ( $total, $item ) { return $total + self::item_amount( $item ); }, 0.00 ); } /** * Get an empty cart structure * * @return array{items: array, total: float, coupon: string} */ private static function empty_cart(): array { return array( 'items' => array(), 'total' => 0.00, 'coupon' => '', ); } /** * Get cart data from session * * @return array{items: array, total: float} */ public static function get() { // Sanitize after the lock is released, since sanitization filters may be slow. list( $available, $raw_data ) = self::with_session( function () { return self::read_raw_session_cart(); } ); return $available ? self::to_cart( $raw_data ) : self::empty_cart(); } /** * Sanitize raw cart session data * * @param mixed $data Raw data retrieved from the session. * * @return mixed Sanitized array or null if input is not an array. */ private static function sanitize_session_data( $data ) { if ( ! is_array( $data ) ) { return null; } $sanitized_items = array(); if ( ! empty( $data['items'] ) && is_array( $data['items'] ) ) { foreach ( $data['items'] as $item ) { if ( ! is_array( $item ) ) { continue; } $sanitized_item = array( 'uuid' => sanitize_text_field( $item['uuid'] ?? '' ), 'service_id' => absint( $item['service_id'] ?? 0 ), 'team_member_id' => absint( $item['team_member_id'] ?? 0 ), 'date' => sanitize_text_field( $item['date'] ?? '' ), 'start_time' => sanitize_text_field( $item['start_time'] ?? '' ), 'end_time' => sanitize_text_field( $item['end_time'] ?? '' ), 'price' => floatval( $item['price'] ?? 0 ), 'subtotal' => floatval( $item['subtotal'] ?? 0 ), 'total' => floatval( $item['total'] ?? 0 ), ); $sanitized_item = apply_filters( 'booktics_pro_sanitize_cart_item', $sanitized_item, $item ); $sanitized_items[] = $sanitized_item; } } return array( 'items' => $sanitized_items, 'total' => floatval( $data['total'] ?? 0.00 ), 'coupon' => sanitize_text_field( $data['coupon'] ?? '' ), ); } /** * Set cart data in session * * @param array $cart Cart data to store in session * * @return bool True when the cart was stored. */ public static function set( array $cart ): bool { list( $available ) = self::with_session( function () use ( $cart ) { $_SESSION[ self::SESSION_KEY ] = $cart; } ); return $available; } /** * Clear cart data from session * * @return bool True when the cart was cleared. */ public static function clear(): bool { list( $available ) = self::with_session( function () { unset( $_SESSION[ self::SESSION_KEY ] ); } ); return $available; } /** * Add an item to the cart session * * @param array $item * * @return array */ public static function add_item( array $item ): array { $cart = self::mutate( function ( $cart ) use ( $item ) { $cart['items'][] = $item; $cart['total'] = floatval( $cart['total'] ?? 0 ) + self::item_amount( $item ); return apply_filters( 'booktics_pro_cart_session', $cart ); } ); if ( null === $cart || empty( $cart['items'] ) ) { return $item; } return end( $cart['items'] ); } /** * Delete an item from the cart session * * @param string $id * * @return void */ public static function delete_item( string $id ) { self::mutate( function ( $cart ) use ( $id ) { $cart['items'] = array_values( array_filter( $cart['items'], function ( $item ) use ( $id ) { return $item['uuid'] !== $id; } ) ); $cart['total'] = self::calculate_total( $cart['items'] ); return $cart; } ); } /** * Edit an item in the cart session * * @param string $uuid UUID of the item to edit * @param array $new_data New data to update the item with * * @return array The updated cart, or an empty cart when the session is unavailable. */ public static function edit_item( string $uuid, array $new_data ): array { $cart = self::mutate( function ( $cart ) use ( $uuid, $new_data ) { $cart['items'] = array_map( function ( $item ) use ( $uuid, $new_data ) { return $item['uuid'] === $uuid ? array_merge( $item, $new_data ) : $item; }, $cart['items'] ); $cart['total'] = self::calculate_total( $cart['items'] ); return apply_filters( 'booktics_pro_cart_session', $cart ); } ); return $cart ?? self::empty_cart(); } }