$status ) ); } public static function filters( array $input ): array|WP_Error { if ( array_diff( array_keys( $input ), array( 'page', 'date_from', 'date_to', 'service_id', 'status', 'show_past' ) ) ) { return self::error( 400 ); } foreach ( array( 'page' => 100000, 'service_id' => 2147483647, ) as $key => $max ) { if ( isset( $input[ $key ] ) ) { if ( ! is_scalar( $input[ $key ] ) || ! preg_match( '/\A[1-9][0-9]{0,9}\z/', (string) $input[ $key ] ) || (int) $input[ $key ] > $max ) { return self::error( 400 ); } $input[ $key ] = (int) $input[ $key ]; } } foreach ( array( 'date_from', 'date_to' ) as $key ) { if ( isset( $input[ $key ] ) ) { $value = $input[ $key ]; if ( ! is_string( $value ) || ! preg_match( '/\A([0-9]{4})-([0-9]{2})-([0-9]{2})\z/', $value, $parts ) || ! checkdate( (int) $parts[2], (int) $parts[3], (int) $parts[1] ) ) { return self::error( 400 ); } } } if ( isset( $input['date_from'], $input['date_to'] ) && $input['date_from'] > $input['date_to'] ) { return self::error( 400 ); } if ( isset( $input['status'] ) && ( ! is_array( $input['status'] ) || ! array_is_list( $input['status'] ) || count( $input['status'] ) < 1 || count( $input['status'] ) > 7 || array_filter( $input['status'], static fn ( $value ) => ! in_array( $value, self::STATUSES, true ) ) ) ) { return self::error( 400 ); } if ( isset( $input['show_past'] ) && ! in_array( $input['show_past'], array( 0, 1, '0', '1' ), true ) ) { return self::error( 400 ); } return $input + array( 'page' => 1 ); } public function catalog( array $input ): array|WP_Error { $query = self::filters( $input ); if ( is_wp_error( $query ) ) { return $query; } $manager = new ConnectionManager(); $status = $manager->status(); if ( is_wp_error( $status ) || ! in_array( $status['status'], array( 'connected', 'offline' ), true ) ) { return self::error( 409 ); } $store = new TokenStore(); $state = $store->read(); if ( ! in_array( 'bookings:read', explode( ' ', $state['scope'] ?? '' ), true ) ) { return new WP_Error( 'admin_read_scope', __( 'Booking read permission requires a new connection approved in Waitlistize. Existing booking widgets keep their current permissions.', 'waitlistize' ), array( 'status' => 403 ) ); } $account_id = $state['account_id'] ?? 0; $connection_id = $state['connection_id'] ?? 0; if ( time() + 60 >= ( $state['expires_at'] ?? 0 ) ) { $check = $manager->check(); if ( is_wp_error( $check ) ) { return self::error( 503 ); } } $mutex = new Mutex(); return $mutex->run( function () use ( $store, $mutex, $query, $account_id, $connection_id ) { $state = $store->read(); if ( $store->disabled() || ! in_array( $state['status'], array( 'connected', 'offline' ), true ) || ! empty( $state['verify_account_slug'] ) || $account_id <= 0 || ( $state['account_id'] ?? 0 ) !== $account_id || ( $state['account']['id'] ?? 0 ) !== $account_id || ( $state['connection_id'] ?? 0 ) !== $connection_id || empty( $state['access_token'] ) || time() >= ( $state['expires_at'] ?? 0 ) || ! in_array( 'bookings:read', explode( ' ', $state['scope'] ?? '' ), true ) ) { return self::error( 409 ); } $origin = ( new Client() )->origin(); if ( is_wp_error( $origin ) ) { return self::error(); } $response = wp_remote_get( add_query_arg( $query, $origin . Client::PREFIX . 'bookings' ), array( 'headers' => array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $state['access_token'], ), 'timeout' => 12, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 1048576, 'cookies' => array(), ) ); $current = $store->read(); if ( ! $mutex->owned() || $store->disabled() || ( $current['account_id'] ?? 0 ) !== $account_id || ( $current['connection_id'] ?? 0 ) !== $connection_id ) { return self::error( 409 ); } if ( is_wp_error( $response ) ) { return self::error( 503 ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return self::error( in_array( $code, array( 401, 403, 404, 422, 429 ), true ) ? $code : 502 ); } return $this->project( json_decode( wp_remote_retrieve_body( $response ), true ), $query['page'], $origin ); } ); } private function project( mixed $data, int $page, string $origin ): array|WP_Error { if ( ! is_array( $data ) || ! is_array( $data['data'] ?? null ) || ! array_is_list( $data['data'] ) || count( $data['data'] ) > 50 || ! is_array( $data['meta'] ?? null ) ) { return self::error(); } $meta = $data['meta']; if ( ( $meta['page'] ?? null ) !== $page || 50 !== ( $meta['per_page'] ?? null ) || ! is_int( $meta['total'] ?? null ) || $meta['total'] < 0 || ! is_int( $meta['total_pages'] ?? null ) || (int) ceil( $meta['total'] / 50 ) !== $meta['total_pages'] || ! is_string( $meta['timezone'] ?? null ) || ! in_array( $meta['timezone'], \DateTimeZone::listIdentifiers( \DateTimeZone::ALL_WITH_BC ), true ) ) { return self::error(); } $rows = array(); $seen = array(); foreach ( $data['data'] as $row ) { if ( ! is_array( $row ) || ! is_int( $row['id'] ?? null ) || $row['id'] < 1 || ! in_array( $row['type'] ?? null, array( 'booking', 'waitlist' ), true ) || ! is_array( $row['service'] ?? null ) || ! is_int( $row['service']['id'] ?? null ) || $row['service']['id'] < 1 || ! is_string( $row['service']['name'] ?? null ) || ! is_string( $row['service']['type'] ?? null ) || ! is_string( $row['customer_name'] ?? null ) || ! in_array( $row['status'] ?? null, self::STATUSES, true ) || ! is_string( $row['admin_url'] ?? null ) ) { return self::error(); } foreach ( array( 'starts_at', 'ends_at' ) as $key ) { if ( ! is_string( $row[ $key ] ?? null ) || ! preg_match( '/\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}[+-][0-9]{2}:[0-9]{2}\z/', $row[ $key ] ) ) { return self::error(); } $date = \DateTimeImmutable::createFromFormat( 'Y-m-d\TH:i:sP', $row[ $key ] ); if ( ! $date || $date->format( 'Y-m-d\TH:i:sP' ) !== $row[ $key ] ) { return self::error(); } } $url = wp_parse_url( $row['admin_url'] ); if ( ! $url || ! str_starts_with( $row['admin_url'], $origin . '/admin/' ) || isset( $url['user'] ) || isset( $url['pass'] ) || isset( $url['fragment'] ) || preg_match( '/[\x00-\x20\\\\]/', $row['admin_url'] ) ) { return self::error(); } $key = $row['type'] . ':' . $row['id']; if ( isset( $seen[ $key ] ) ) { return self::error(); } $seen[ $key ] = true; $clean = array_intersect_key( $row, array_flip( array( 'id', 'type', 'starts_at', 'ends_at', 'customer_name', 'status', 'admin_url' ) ) ); $clean['service'] = array_intersect_key( $row['service'], array_flip( array( 'id', 'name', 'type' ) ) ); $rows[] = $clean; } return array( 'data' => $rows, 'meta' => array_intersect_key( $meta, array_flip( array( 'page', 'per_page', 'total', 'total_pages', 'timezone' ) ) ), ); } }