rate_limiter = new RateLimiter(); $this->formatter = new ResponseFormatter(); } /** * Check rate limit before processing request. * * @param WP_REST_Request $request Current request. * @return WP_Error|null Error if rate limited, null otherwise. */ protected function check_rate_limit( WP_REST_Request $request ): ?WP_Error { // Skip rate limiting if disabled. if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) { return null; } $identifier = $this->get_client_identifier( $request ); $endpoint = $request->get_route(); if ( ! $this->rate_limiter->check( $identifier, $endpoint ) ) { return $this->formatter->rate_limit_error( $this->rate_limiter->get_retry_after( $identifier, $endpoint ) ); } return null; } /** * Add rate limit headers to response. * * @param WP_REST_Response $response Current response. * @param WP_REST_Request $request Current request. * @return WP_REST_Response Response with headers. */ protected function add_rate_limit_headers( WP_REST_Response $response, WP_REST_Request $request ): WP_REST_Response { if ( 'yes' !== get_option( 'wp_bnb_api_rate_limiting', 'yes' ) ) { return $response; } $identifier = $this->get_client_identifier( $request ); $endpoint = $request->get_route(); $info = $this->rate_limiter->get_rate_limit_info( $identifier, $endpoint ); $response->header( 'X-RateLimit-Limit', (string) $info['limit'] ); $response->header( 'X-RateLimit-Remaining', (string) $info['remaining'] ); $response->header( 'X-RateLimit-Reset', (string) $info['reset'] ); return $response; } /** * Get client identifier for rate limiting. * * @param WP_REST_Request $request Current request. * @return string Client identifier. */ protected function get_client_identifier( WP_REST_Request $request ): string { // Use user ID if authenticated. $user_id = get_current_user_id(); if ( $user_id > 0 ) { return 'user_' . $user_id; } return 'ip_' . $this->get_client_ip(); } /** * Get client IP address. * * Supports proxies and Cloudflare. * * @return string Client IP address. */ protected function get_client_ip(): string { $headers = array( 'HTTP_CF_CONNECTING_IP', // Cloudflare. 'HTTP_X_FORWARDED_FOR', // Proxy. 'HTTP_X_REAL_IP', // Nginx. 'REMOTE_ADDR', ); foreach ( $headers as $header ) { if ( ! empty( $_SERVER[ $header ] ) ) { $ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ); // Handle comma-separated list (X-Forwarded-For). if ( str_contains( $ip, ',' ) ) { $ip = trim( explode( ',', $ip )[0] ); } return $ip; } } return '127.0.0.1'; } /** * Validate date format (Y-m-d). * * @param string $date Date string. * @return bool True if valid. */ protected function validate_date( string $date ): bool { $d = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); return $d && $d->format( 'Y-m-d' ) === $date; } /** * Validate date is not in the past. * * @param string $date Date string (Y-m-d). * @return bool True if date is today or future. */ protected function validate_future_date( string $date ): bool { if ( ! $this->validate_date( $date ) ) { return false; } $date_obj = \DateTimeImmutable::createFromFormat( 'Y-m-d', $date ); $today = new \DateTimeImmutable( 'today' ); return $date_obj >= $today; } /** * Permission callback for public endpoints. * * @return bool Always true. */ public function public_permission(): bool { return true; } /** * Permission callback for authenticated endpoints. * * @return bool True if logged in. */ public function authenticated_permission(): bool { return is_user_logged_in(); } /** * Permission callback for admin endpoints. * * @return bool True if user can edit posts. */ public function admin_permission(): bool { return current_user_can( 'edit_posts' ); } /** * Permission callback for managing bookings. * * @return bool True if user can edit posts. */ public function manage_bookings_permission(): bool { return current_user_can( 'edit_posts' ); } /** * Get pagination parameters from request. * * @param WP_REST_Request $request Current request. * @return array{page: int, per_page: int, offset: int} */ protected function get_pagination_params( WP_REST_Request $request ): array { $page = max( 1, (int) $request->get_param( 'page' ) ?: 1 ); $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ?: 10 ) ); $offset = ( $page - 1 ) * $per_page; return array( 'page' => $page, 'per_page' => $per_page, 'offset' => $offset, ); } /** * Get sorting parameters from request. * * @param WP_REST_Request $request Current request. * @param array $allowed_orderby Allowed orderby values. * @param string $default_orderby Default orderby value. * @return array{orderby: string, order: string} */ protected function get_sorting_params( WP_REST_Request $request, array $allowed_orderby = array( 'title', 'date' ), string $default_orderby = 'title' ): array { $orderby = $request->get_param( 'orderby' ) ?: $default_orderby; $order = strtoupper( $request->get_param( 'order' ) ?: 'ASC' ); // Validate orderby. if ( ! in_array( $orderby, $allowed_orderby, true ) ) { $orderby = $default_orderby; } // Validate order. if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) { $order = 'ASC'; } return array( 'orderby' => $orderby, 'order' => $order, ); } /** * Format post for API response. * * @param \WP_Post $post Post object. * @return array Basic post data. */ protected function format_post_base( \WP_Post $post ): array { return array( 'id' => $post->ID, 'title' => get_the_title( $post ), 'slug' => $post->post_name, 'excerpt' => get_the_excerpt( $post ), 'content' => apply_filters( 'the_content', $post->post_content ), ); } /** * Format featured image for API response. * * @param int $post_id Post ID. * @return array|null Image data or null. */ protected function format_featured_image( int $post_id ): ?array { $thumbnail_id = get_post_thumbnail_id( $post_id ); if ( ! $thumbnail_id ) { return null; } return $this->format_image( $thumbnail_id ); } /** * Format image attachment for API response. * * @param int $attachment_id Attachment ID. * @return array|null Image data or null. */ protected function format_image( int $attachment_id ): ?array { $full = wp_get_attachment_image_src( $attachment_id, 'full' ); if ( ! $full ) { return null; } $sizes = array(); foreach ( array( 'thumbnail', 'medium', 'large' ) as $size ) { $src = wp_get_attachment_image_src( $attachment_id, $size ); if ( $src ) { $sizes[ $size ] = array( 'url' => $src[0], 'width' => $src[1], 'height' => $src[2], ); } } return array( 'id' => $attachment_id, 'url' => $full[0], 'width' => $full[1], 'height' => $full[2], 'alt' => get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ), 'sizes' => $sizes, ); } /** * Add HATEOAS links to response item. * * @param array $item Response item. * @param string $route Base route for self link. * @param int $id Item ID. * @return array Item with _links. */ protected function add_links( array $item, string $route, int $id ): array { $item['_links'] = array( 'self' => array( array( 'href' => rest_url( $this->namespace . '/' . $route . '/' . $id ), ), ), ); return $item; } /** * Get common collection parameters for schema. * * @return array Collection parameters. */ public function get_collection_params(): array { return array( 'page' => array( 'description' => __( 'Current page of the collection.', 'wp-bnb' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint', ), 'per_page' => array( 'description' => __( 'Maximum number of items to be returned per page.', 'wp-bnb' ), 'type' => 'integer', 'default' => 10, 'minimum' => 1, 'maximum' => 100, 'sanitize_callback' => 'absint', ), 'search' => array( 'description' => __( 'Limit results to those matching a string.', 'wp-bnb' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'orderby' => array( 'description' => __( 'Sort collection by attribute.', 'wp-bnb' ), 'type' => 'string', 'default' => 'title', 'sanitize_callback' => 'sanitize_text_field', ), 'order' => array( 'description' => __( 'Order sort attribute ascending or descending.', 'wp-bnb' ), 'type' => 'string', 'default' => 'asc', 'enum' => array( 'asc', 'desc' ), 'sanitize_callback' => 'sanitize_text_field', ), ); } }