383 lines
9.8 KiB
PHP
383 lines
9.8 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* Abstract REST Controller
|
||
|
|
*
|
||
|
|
* Base class for all REST API controllers with common functionality.
|
||
|
|
*
|
||
|
|
* @package Magdev\WpBnb\Api\Controllers
|
||
|
|
*/
|
||
|
|
|
||
|
|
declare( strict_types=1 );
|
||
|
|
|
||
|
|
namespace Magdev\WpBnb\Api\Controllers;
|
||
|
|
|
||
|
|
use Magdev\WpBnb\Api\RestApi;
|
||
|
|
use Magdev\WpBnb\Api\RateLimiter;
|
||
|
|
use Magdev\WpBnb\Api\ResponseFormatter;
|
||
|
|
use WP_REST_Controller;
|
||
|
|
use WP_REST_Request;
|
||
|
|
use WP_REST_Response;
|
||
|
|
use WP_Error;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Abstract Controller class.
|
||
|
|
*/
|
||
|
|
abstract class AbstractController extends WP_REST_Controller {
|
||
|
|
|
||
|
|
/**
|
||
|
|
* API namespace.
|
||
|
|
*
|
||
|
|
* @var string
|
||
|
|
*/
|
||
|
|
protected $namespace = RestApi::NAMESPACE;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Rate limiter instance.
|
||
|
|
*
|
||
|
|
* @var RateLimiter
|
||
|
|
*/
|
||
|
|
protected RateLimiter $rate_limiter;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Response formatter instance.
|
||
|
|
*
|
||
|
|
* @var ResponseFormatter
|
||
|
|
*/
|
||
|
|
protected ResponseFormatter $formatter;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Constructor.
|
||
|
|
*/
|
||
|
|
public function __construct() {
|
||
|
|
$this->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',
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|