'GET', 'permission_callback' => '__return_true', 'callback' => array( $this, 'show' ), ), array( 'methods' => 'POST', 'permission_callback' => array( $this, 'permission' ), 'callback' => array( $this, 'store' ), ), ) ); } public function permission( WP_REST_Request $request ): bool|WP_Error { $origin = $request->get_header( 'origin' ); $home = wp_parse_url( home_url() ); $parts = wp_parse_url( $origin ); if ( ! $parts || isset( $parts['user'] ) || ( $parts['scheme'] ?? '' ) !== $home['scheme'] || ( $parts['host'] ?? '' ) !== $home['host'] || ( $parts['port'] ?? null ) !== ( $home['port'] ?? null ) ) { return PublicBooking::error( 'booking_origin', 403 ); } return true; } private function reply( array|WP_Error $data ): WP_REST_Response { $response = is_wp_error( $data ) ? rest_convert_error_to_response( $data ) : new WP_REST_Response( $data ); $response->header( 'Cache-Control', 'no-store, private' ); $response->header( 'Pragma', 'no-cache' ); return $response; } public function show( WP_REST_Request $request ): WP_REST_Response { $query = $request->get_query_params(); unset( $query['rest_route'] ); if ( array_diff_key( $query, array_flip( array( 'embed', 'date' ) ) ) || ( isset( $query['date'] ) && ( ! is_string( $query['date'] ) || ! preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $query['date'] ) || gmdate( 'Y-m-d', (int) strtotime( $query['date'] ) ) !== $query['date'] ) ) ) { return $this->reply( PublicBooking::error( 'booking_parameters', 400 ) ); } $binding = BookingTicket::read( $query['embed'] ?? null, 'embed' ); if ( is_wp_error( $binding ) ) { return $this->reply( $binding ); } $api = new PublicBooking(); $params = 'auto' === $binding['locale'] ? array() : array( 'locale' => $binding['locale'] ); $detail = $api->request( $binding, 'services/' . $binding['service'], $params ); if ( is_wp_error( $detail ) ) { return $this->reply( $detail ); } $service = $this->service( $detail['data'] ?? null, $binding ); if ( is_wp_error( $service ) ) { return $this->reply( $service ); } try { $today = ( new \DateTimeImmutable( 'now', new \DateTimeZone( $service['timezone'] ) ) )->format( 'Y-m-d' ); } catch ( \Exception $exception ) { return $this->reply( PublicBooking::error( 'booking_contract', 502 ) ); } $params['locale'] = $service['locale']; // Both L3 endpoints scope an explicit date to its account-local week. $params['date'] = $query['date'] ?? $today; $offers = $api->request( $binding, 'services/' . $binding['service'] . ( 'single' === $service['type'] ? '/slots' : '/occurrences' ), $params ); if ( is_wp_error( $offers ) ) { return $this->reply( $offers ); } if ( ! is_array( $offers['data'] ?? null ) || ! is_array( $offers['meta'] ?? null ) ) { return $this->reply( PublicBooking::error( 'booking_contract', 502 ) ); } $rows = array(); foreach ( $offers['data'] as $row ) { if ( ! is_array( $row ) || ! is_string( $row['start_utc'] ?? null ) || ! is_string( $row['start_local'] ?? null ) || ! is_string( $row['end_local'] ?? null ) || ! is_int( $row[ 'single' === $service['type'] ? 'resource_id' : 'id' ] ?? null ) ) { return $this->reply( PublicBooking::error( 'booking_contract', 502 ) ); } if ( 'single' === $service['type'] && 'hidden' === $binding['selector'] && $row['resource_id'] !== $service['default_resource_id'] ) { continue; } $keys = 'single' === $service['type'] ? array( 'resource_id', 'start_utc', 'end_utc', 'start_local', 'end_local' ) : array( 'id', 'start_utc', 'end_utc', 'start_local', 'end_local', 'capacity', 'remaining', 'waitlist_count' ); $rows[] = array_intersect_key( $row, array_flip( $keys ) ); } $meta = array_intersect_key( $offers['meta'], array_flip( array( 'timezone', 'locale', 'date', 'nav_max_date', 'limit' ) ) ); $meta['today'] = $today; if ( 'group' === $service['type'] ) { // L3 occurrences deliberately omits date; retain the requested display week. $meta['date'] = $params['date']; } $form = $binding; $form['kind'] = 'form'; $form['locale'] = $service['locale']; $form['type'] = $service['type']; $form['resource'] = 'single' === $service['type'] && 'hidden' === $binding['selector'] ? $service['default_resource_id'] : null; $form['loaded'] = time(); $form['expires'] = time() + 2 * HOUR_IN_SECONDS; $form['honeypot'] = '_hp_' . wp_generate_password( 8, false, false ); return $this->reply( array( 'service' => $service, 'data' => $rows, 'meta' => $meta, 'ticket' => BookingTicket::issue( $form ), 'form_loaded_at' => $form['loaded'], 'honeypot' => $form['honeypot'], ) ); } private function service( mixed $row, array $binding ): array|WP_Error { if ( ! is_array( $row ) || ( $row['id'] ?? null ) !== $binding['service'] || ! in_array( $row['type'] ?? '', array( 'single', 'group' ), true ) || ! in_array( $row['locale'] ?? '', array( 'hu', 'en', 'de' ), true ) || ! is_array( $row['form']['required_fields'] ?? null ) || ! is_string( $row['form']['gdpr']['label_html'] ?? null ) ) { return PublicBooking::error( 'booking_contract', 502 ); } $required = array(); foreach ( array( 'customer_name', 'customer_email', 'customer_phone' ) as $key ) { if ( ! is_bool( $row['form']['required_fields'][ $key ] ?? null ) ) { return PublicBooking::error( 'booking_contract', 502 ); } $required[ $key ] = $row['form']['required_fields'][ $key ]; } $resources = array(); foreach ( $row['resources'] ?? array() as $resource ) { if ( is_int( $resource['id'] ?? null ) && is_string( $resource['name'] ?? null ) ) { $resources[] = array_intersect_key( $resource, array_flip( array( 'id', 'name' ) ) ); } } $default = $row['default_resource_id'] ?? null; if ( null !== $default && ( ! is_int( $default ) || $default <= 0 || ! in_array( $default, array_column( $resources, 'id' ), true ) ) ) { return PublicBooking::error( 'booking_contract', 502 ); } if ( 'single' === $row['type'] && 'hidden' === $binding['selector'] ) { if ( ! array_key_exists( 'default_resource_id', $row ) ) { return PublicBooking::error( 'booking_contract', 502 ); } if ( null === $default ) { return PublicBooking::error( 'booking_default_resource', 409 ); } } return array( 'id' => $row['id'], 'type' => $row['type'], 'locale' => $row['locale'] ?? 'en', 'timezone' => $row['timezone'] ?? '', 'resources' => 'hidden' === $binding['selector'] ? array() : $resources, 'resource_selector' => $binding['selector'], 'default_resource_id' => $default, 'form' => array( 'required_fields' => $required, 'gdpr' => array( 'label_html' => wp_kses( $row['form']['gdpr']['label_html'], array( 'a' => array( 'href' => true, 'target' => true, 'rel' => true, ), 'strong' => array(), 'em' => array(), 'br' => array(), ) ), ), 'listamester_consent' => array( 'enabled' => true === ( $row['form']['listamester_consent']['enabled'] ?? false ), 'text' => is_string( $row['form']['listamester_consent']['text'] ?? null ) ? $row['form']['listamester_consent']['text'] : '', ), ), ); } public function store( WP_REST_Request $request ): WP_REST_Response { if ( strlen( $request->get_body() ) > 32768 ) { return $this->reply( PublicBooking::error( 'booking_parameters', 413 ) ); } $input = $request->get_json_params(); if ( ! is_array( $input ) ) { return $this->reply( PublicBooking::error( 'booking_parameters', 415 ) ); } $query = $request->get_query_params(); unset( $query['rest_route'] ); $form = BookingTicket::read( $input['ticket'] ?? null, 'form' ); if ( is_wp_error( $form ) ) { return $this->reply( $form ); } $keys = array( 'ticket', 'form_loaded_at', $form['honeypot'], 'customer_name', 'customer_email', 'customer_phone', 'gdpr', 'listamester_consent' ); $keys = array_merge( $keys, 'single' === $form['type'] ? array( 'resource_id', 'start_utc' ) : array( 'occurrence_id' ) ); if ( $query || array_diff_key( $input, array_flip( $keys ) ) || ( $input['form_loaded_at'] ?? null ) !== $form['loaded'] || ! isset( $input[ $form['honeypot'] ] ) || ! is_string( $input[ $form['honeypot'] ] ) ) { return $this->reply( PublicBooking::error( 'booking_parameters', 400 ) ); } foreach ( array( 'customer_name', 'customer_email', 'customer_phone', 'start_utc' ) as $key ) { if ( isset( $input[ $key ] ) && ( ! is_string( $input[ $key ] ) || strlen( $input[ $key ] ) > 4096 ) ) { return $this->reply( PublicBooking::error( 'booking_parameters', 400 ) ); } } if ( 'single' === $form['type'] && 'hidden' === $form['selector'] && ( ! is_int( $form['resource'] ?? null ) || ( $input['resource_id'] ?? null ) !== $form['resource'] ) ) { return $this->reply( PublicBooking::error( 'booking_parameters', 400 ) ); } // REMOTE_ADDR must be normalized by the hosting's trusted proxy, never browser JSON/Forwarded. $ip = isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $this->reply( PublicBooking::error( 'booking_visitor', 503 ) ); } unset( $input['ticket'] ); $input['service_id'] = $form['service']; $input['visitor'] = array( 'ip' => $ip, 'user_agent' => $request->get_header( 'user-agent' ) ? $request->get_header( 'user-agent' ) : null, ); $params = 'auto' === $form['locale'] ? array() : array( 'locale' => $form['locale'] ); $result = ( new PublicBooking() )->request( $form, 'bookings', $params, $input ); if ( is_wp_error( $result ) ) { return $this->reply( $result ); } if ( ! array_key_exists( 'result', $result ) ) { return $this->reply( PublicBooking::error( 'booking_contract', 502 ) ); } if ( null === $result['result'] ) { return $this->reply( array( 'result' => null ) ); } $row = $result['result']; if ( ! is_array( $row ) || ! in_array( $row['status'] ?? '', array( 'confirmed', 'pending', 'waitlisted' ), true ) || ( 'waitlisted' !== $row['status'] && ( ! is_int( $row['booking_id'] ?? null ) || $row['booking_id'] <= 0 ) ) ) { return $this->reply( PublicBooking::error( 'booking_contract', 502 ) ); } $public = array_intersect_key( $row, array_flip( array( 'status', 'booking_id' ) ) ); if ( 'waitlisted' !== $row['status'] && isset( $row['redirect_url'] ) ) { $url = $row['redirect_url']; if ( ! is_string( $url ) || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'https', 'http' ), true ) || ! wp_parse_url( $url, PHP_URL_HOST ) ) { return $this->reply( PublicBooking::error( 'booking_contract', 502 ) ); } $public['redirect_url'] = $url; } return $this->reply( array( 'result' => $public ) ); } }