d040400da8
4 個 permission_callback => '__return_true' 的公開端點先前回傳 hot+cold 全部欄位,未註冊 show_in_rest 的私密欄位隨之外洩(A v3.3.1,CVSS 5.3)。 - Schema_Registry::register() 新增 show_in_rest 預設 true - 新增 get_rest_visible_hot_columns() / get_rest_visible_cold_keys() - get_listing() 以 array_intersect_key 過濾,保留 id / post_type, 並提供 wpdo_rest_listing_visible_fields / tmdo_* filter 供調整 - listings_from_zone_a() 同樣以 visible_cols 過濾 unit 379 / integration 398 GREEN Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
1644 lines
56 KiB
PHP
1644 lines
56 KiB
PHP
<?php
|
|
/**
|
|
* REST API endpoints for WP Data Optimizer.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* REST API for WP Data Optimizer — /wp-json/wpdo/v1/
|
|
*
|
|
* Endpoints:
|
|
* GET /wpdo/v1/listings — Query Zone A (search/filter)
|
|
* GET /wpdo/v1/listings/{id} — Single listing (Zone A + Zone C merged)
|
|
* GET /wpdo/v1/stats/{id} — Zone B view count
|
|
* POST /wpdo/v1/listings/{id}/view — Increment Zone B view count (requires nonce)
|
|
* GET /wpdo/v1/status — API / zone health info
|
|
*/
|
|
class TMDO_REST_API {
|
|
|
|
const NAMESPACE = 'wpdo/v1';
|
|
|
|
/**
|
|
* Registers all REST API routes.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function register_routes(): void {
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/listings',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'get_listings' ),
|
|
'permission_callback' => '__return_true',
|
|
'args' => $this->listings_args(),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/listings/(?P<id>\d+)',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'get_listing' ),
|
|
'permission_callback' => '__return_true',
|
|
'args' => array(
|
|
'id' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/stats/(?P<id>\d+)',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'get_stats' ),
|
|
'permission_callback' => '__return_true',
|
|
'args' => array(
|
|
'id' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/listings/(?P<id>\d+)/view',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'post_view' ),
|
|
'permission_callback' => '__return_true',
|
|
'args' => array(
|
|
'id' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v > 0,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/status',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'get_status' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
|
|
// ── Entity Bridge 端點 (v2.6.6) ───────────────────────────────────────
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/entity-bridge/health',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'entity_bridge_health_all' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/entity-bridge/health/(?P<type>[a-z]+)',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'entity_bridge_health_one' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'type' => array(
|
|
'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/entity-bridge/backfill',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'entity_bridge_start_backfill' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'entity_type' => array(
|
|
'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
'group_name' => array(
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/entity-bridge/promote',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'entity_bridge_promote' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'entity_type' => array(
|
|
'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/entity-bridge/demote',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'entity_bridge_demote' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'entity_type' => array(
|
|
'validate_callback' => fn( $v ) => in_array( $v, array( 'user', 'term', 'comment', 'post' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
|
|
// v2.6.7: User stress-test endpoints.
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/stress-test/status',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'stress_test_status' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/stress-test/start',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'stress_test_start' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'target' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
'mode' => array(
|
|
'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => false,
|
|
),
|
|
'batch_size' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 2000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => false,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/stress-test/cancel',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'stress_test_cancel' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/stress-test/cleanup',
|
|
array(
|
|
'methods' => WP_REST_Server::DELETABLE,
|
|
'callback' => array( $this, 'stress_test_cleanup' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/stress-test/benchmark',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'stress_test_run_benchmark' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
|
|
// v2.11.4: Post stress-test endpoints (mirror of user-side, post_type-scoped).
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/post-stress-test/status',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'post_stress_test_status' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/post-stress-test/start',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'post_stress_test_start' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'post_type' => array(
|
|
'validate_callback' => fn( $v ) => in_array(
|
|
$v,
|
|
array( 'product', 'hp_listing', 'hp_request', 'hp_vendor', 'attachment', 'nav_menu_item', 'post' ),
|
|
true
|
|
),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
'target' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
'mode' => array(
|
|
'validate_callback' => fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => false,
|
|
),
|
|
'batch_size' => array(
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => false,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/post-stress-test/cancel',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'post_stress_test_cancel' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/post-stress-test/cleanup',
|
|
array(
|
|
'methods' => WP_REST_Server::DELETABLE,
|
|
'callback' => array( $this, 'post_stress_test_cleanup' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/post-stress-test/benchmark',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'post_stress_test_run_benchmark' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
|
|
// v2.13.0: Term stress-test endpoints (mirror post side, taxonomy-scoped).
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/term-stress-test/status',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'term_stress_test_status' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/term-stress-test/start',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'term_stress_test_start' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'taxonomy' => array(
|
|
'validate_callback' => static fn( $v ) => is_string( $v ) && '' !== $v && taxonomy_exists( $v ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => true,
|
|
),
|
|
'target' => array(
|
|
'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
'mode' => array(
|
|
'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => false,
|
|
),
|
|
'batch_size' => array(
|
|
'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => false,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/term-stress-test/cancel',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'term_stress_test_cancel' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/term-stress-test/cleanup',
|
|
array(
|
|
'methods' => WP_REST_Server::DELETABLE,
|
|
'callback' => array( $this, 'term_stress_test_cleanup' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/term-stress-test/benchmark',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'term_stress_test_run_benchmark' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
|
|
// v2.13.1: Comment stress-test endpoints (mirror term side, post-scoped).
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/comment-stress-test/status',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'comment_stress_test_status' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/comment-stress-test/start',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'comment_stress_test_start' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'post_id' => array(
|
|
'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
'target' => array(
|
|
'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 100000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => true,
|
|
),
|
|
'mode' => array(
|
|
'validate_callback' => static fn( $v ) => in_array( $v, array( 'fast', 'realistic' ), true ),
|
|
'sanitize_callback' => 'sanitize_key',
|
|
'required' => false,
|
|
),
|
|
'batch_size' => array(
|
|
'validate_callback' => static fn( $v ) => is_numeric( $v ) && $v >= 1 && $v <= 1000,
|
|
'sanitize_callback' => 'absint',
|
|
'required' => false,
|
|
),
|
|
),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/comment-stress-test/cancel',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'comment_stress_test_cancel' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/comment-stress-test/cleanup',
|
|
array(
|
|
'methods' => WP_REST_Server::DELETABLE,
|
|
'callback' => array( $this, 'comment_stress_test_cleanup' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/comment-stress-test/benchmark',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'comment_stress_test_run_benchmark' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
|
|
// ── Migration Wizard endpoints (v2.8.0) ───────────────────────────
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/migration/preflight',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'migration_preflight' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/migration/start',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'migration_start' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
'args' => array(
|
|
'verify_strict' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
|
|
'verify_24h' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
|
|
'auto_backup' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
|
|
'force_async' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
|
|
'dry_run' => array( 'sanitize_callback' => 'rest_sanitize_boolean' ),
|
|
),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/migration/status',
|
|
array(
|
|
'methods' => WP_REST_Server::READABLE,
|
|
'callback' => array( $this, 'migration_status' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/migration/cancel',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'migration_cancel' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
register_rest_route(
|
|
self::NAMESPACE,
|
|
'/migration/resume',
|
|
array(
|
|
'methods' => WP_REST_Server::CREATABLE,
|
|
'callback' => array( $this, 'migration_resume' ),
|
|
'permission_callback' => array( $this, 'require_manage_options' ),
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/listings
|
|
*
|
|
* Query Zone A flat table (if cutover) or fall back to WP_Query.
|
|
* Supports pagination and per-column numeric filters.
|
|
*
|
|
* @param WP_REST_Request $request REST request object.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function get_listings( WP_REST_Request $request ): WP_REST_Response {
|
|
$post_type = $request->get_param( 'post_type' ) ?? 'hp_listing';
|
|
// Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open.
|
|
// Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound.
|
|
$max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 );
|
|
$per_page = max( 1, min( $max_per_page, (int) ( $request->get_param( 'per_page' ) ?? 20 ) ) );
|
|
$page = max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) );
|
|
$orderby = $request->get_param( 'orderby' ) ?? 'post_id';
|
|
$order = strtoupper( (string) ( $request->get_param( 'order' ) ?? 'DESC' ) );
|
|
$offset = ( $page - 1 ) * $per_page;
|
|
|
|
$module = 'hot_' . sanitize_key( $post_type );
|
|
|
|
if ( TMDO_Feature_Flags::is_read_custom( $module ) ) {
|
|
return $this->listings_from_zone_a( $post_type, $per_page, $offset, $orderby, $order, $request );
|
|
}
|
|
|
|
return $this->listings_from_wp_query( $post_type, $per_page, $page, $request );
|
|
}
|
|
|
|
/**
|
|
* GET /wpdo/v1/listings/{id}
|
|
*
|
|
* Returns Zone A fields + Zone C blob merged (or postmeta fallback).
|
|
*
|
|
* @param WP_REST_Request $request REST request object.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function get_listing( WP_REST_Request $request ): WP_REST_Response {
|
|
$post_id = (int) $request->get_param( 'id' );
|
|
|
|
// v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1).
|
|
$err = $this->assert_post_viewable_or_error( $post_id );
|
|
if ( null !== $err ) {
|
|
return $err;
|
|
}
|
|
|
|
$post_type = get_post_type( $post_id );
|
|
|
|
$data = array(
|
|
'id' => $post_id,
|
|
'post_type' => $post_type,
|
|
);
|
|
|
|
// Zone A.
|
|
$hot_module = 'hot_' . sanitize_key( $post_type );
|
|
if ( TMDO_Feature_Flags::is_read_custom( $hot_module ) ) {
|
|
$hot = TMDO_Zone_Hot::get_row( $post_id, $post_type );
|
|
if ( $hot ) {
|
|
unset( $hot['post_id'], $hot['updated_at'] );
|
|
$data = array_merge( $data, $hot );
|
|
}
|
|
} else {
|
|
foreach ( array_keys( TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ) ) as $col ) {
|
|
$data[ $col ] = get_post_meta( $post_id, $col, true );
|
|
}
|
|
}
|
|
|
|
// Zone C.
|
|
$cold_module = 'cold_' . sanitize_key( $post_type );
|
|
if ( TMDO_Feature_Flags::is_read_custom( $cold_module ) ) {
|
|
$cold = TMDO_Zone_Cold::get_blob( $post_id, $post_type );
|
|
$data = array_merge( $data, $cold );
|
|
} else {
|
|
foreach ( TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ) as $key ) {
|
|
$data[ $key ] = get_post_meta( $post_id, $key, true );
|
|
}
|
|
}
|
|
|
|
// Strip fields not marked show_in_rest => true in Schema_Registry.
|
|
// 'id' and 'post_type' are always retained. The filter below can add/remove
|
|
// fields on top of this default restriction.
|
|
$registry = TMDO_Schema_Registry::instance();
|
|
$visible_keys = array_fill_keys(
|
|
array_merge(
|
|
array( 'id', 'post_type' ),
|
|
$registry->get_rest_visible_hot_columns( $post_type ),
|
|
$registry->get_rest_visible_cold_keys( $post_type )
|
|
),
|
|
true
|
|
);
|
|
$data = array_intersect_key( $data, $visible_keys );
|
|
|
|
/**
|
|
* Filters the fields returned by GET /wpdo/v1/listings/{id}.
|
|
*
|
|
* Called after the default show_in_rest restriction. Use this hook to expose
|
|
* additional private fields to authenticated users, or to remove fields for
|
|
* specific consumers.
|
|
*
|
|
* @param array $data Field map (already restricted to show_in_rest=true fields).
|
|
* @param int $post_id Post ID.
|
|
* @param string $post_type Post type slug.
|
|
*/
|
|
$data = (array) apply_filters( 'wpdo_rest_listing_visible_fields', $data, $post_id, $post_type );
|
|
/** This filter is documented above (tmdo_* is the forward-looking name). */
|
|
$data = (array) apply_filters( 'tmdo_rest_listing_visible_fields', $data, $post_id, $post_type );
|
|
|
|
return new WP_REST_Response( $data, 200 );
|
|
}
|
|
|
|
/**
|
|
* GET /wpdo/v1/stats/{id}
|
|
*
|
|
* Returns Zone B view count (with postmeta fallback).
|
|
*
|
|
* @param WP_REST_Request $request REST request object.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function get_stats( WP_REST_Request $request ): WP_REST_Response {
|
|
$post_id = (int) $request->get_param( 'id' );
|
|
|
|
// v2.13.3: post_status / read_post capability guard (fixes M-AUTH-1).
|
|
$err = $this->assert_post_viewable_or_error( $post_id );
|
|
if ( null !== $err ) {
|
|
return $err;
|
|
}
|
|
|
|
$views = TMDO_Listing_Stats::get_view_count( $post_id );
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'post_id' => $post_id,
|
|
'view_count' => (int) $views,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/listings/{id}/view
|
|
*
|
|
* Increment Zone B view count for a post.
|
|
* Requires a valid WP REST nonce (X-WP-Nonce header or _wpnonce query param).
|
|
* Rate-limited to one increment per IP per post per hour (transient).
|
|
* Returns the new view count.
|
|
*
|
|
* @param WP_REST_Request $request REST request object.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function post_view( WP_REST_Request $request ): WP_REST_Response {
|
|
// Verify nonce — prevents CSRF from third-party sites.
|
|
$nonce = $request->get_header( 'X-WP-Nonce' )
|
|
?? $request->get_param( '_wpnonce' )
|
|
?? '';
|
|
|
|
if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
|
|
return new WP_REST_Response(
|
|
array(
|
|
'code' => 'rest_forbidden',
|
|
'message' => 'Invalid or missing nonce.',
|
|
),
|
|
403
|
|
);
|
|
}
|
|
|
|
$post_id = (int) $request->get_param( 'id' );
|
|
|
|
// v2.13.3: post_status / read_post capability guard (fixes M-LOGIC-1).
|
|
// Prevents accumulating view counts on draft / private / trash posts.
|
|
$err = $this->assert_post_viewable_or_error( $post_id );
|
|
if ( null !== $err ) {
|
|
return $err;
|
|
}
|
|
|
|
// Cookie-based dedup — browser clients won't re-count on page reload.
|
|
$cookie_key = 'wpdo_view_' . $post_id;
|
|
$cookie_blocked = ! empty( $_COOKIE[ $cookie_key ] );
|
|
|
|
// IP-based rate limit — one increment per IP per post per hour.
|
|
// v2.13.3: behind a reverse proxy REMOTE_ADDR collapses to the proxy IP
|
|
// and the rate limit degenerates. Site owners can hook `wpdo_view_client_ip`
|
|
// to resolve X-Forwarded-For (or another trusted header) per their topology.
|
|
// Default keeps REMOTE_ADDR (no implicit trust of forwarded headers).
|
|
$ip_default = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1';
|
|
$ip = (string) apply_filters( 'wpdo_view_client_ip', $ip_default, $request );
|
|
$rl_key = 'wpdo_view_' . $post_id . '_' . substr( md5( $ip ), 0, 12 ); // Max 43 chars.
|
|
$ip_blocked = (bool) get_transient( $rl_key );
|
|
|
|
if ( $cookie_blocked || $ip_blocked ) {
|
|
// Track rate-limit hits per post for admin visibility, capped at 100 entries.
|
|
$rl_stats = get_option( 'wpdo_rl_stats', array() );
|
|
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1;
|
|
if ( count( $rl_stats ) > 100 ) {
|
|
// Keep only the top-100 posts by hit count to bound option size.
|
|
arsort( $rl_stats );
|
|
$rl_stats = array_slice( $rl_stats, 0, 100, true );
|
|
}
|
|
update_option( 'wpdo_rl_stats', $rl_stats, false );
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'code' => 'too_many_requests',
|
|
'message' => 'View already counted for this session.',
|
|
),
|
|
429
|
|
);
|
|
}
|
|
|
|
// Mark as counted for this IP/session.
|
|
set_transient( $rl_key, 1, HOUR_IN_SECONDS );
|
|
|
|
TMDO_Listing_Stats::increment_view( $post_id );
|
|
$views = TMDO_Listing_Stats::get_view_count( $post_id );
|
|
|
|
$response = new WP_REST_Response(
|
|
array(
|
|
'post_id' => $post_id,
|
|
'view_count' => (int) $views,
|
|
),
|
|
200
|
|
);
|
|
// Tell browsers not to re-count on page reload.
|
|
$response->header( 'Set-Cookie', "{$cookie_key}=1; Max-Age=3600; Path=/; SameSite=Strict" );
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* GET /wpdo/v1/status (requires manage_options)
|
|
*
|
|
* Returns zone module states and registered field counts.
|
|
*
|
|
* @param WP_REST_Request $request REST request object. Not used directly.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function get_status( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Required by WP REST API callback signature.
|
|
$registry = TMDO_Schema_Registry::instance();
|
|
$modules = TMDO_Feature_Flags::all();
|
|
$stats = $registry->get_stats();
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'version' => TMDO_VERSION,
|
|
'engine' => TMDO_IS_SQLITE ? 'sqlite' : 'mysql',
|
|
'fields' => $stats,
|
|
'modules' => $modules,
|
|
'rate_limit_stats' => get_option( 'wpdo_rl_stats', array() ),
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
// ── Permission ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Permission callback that requires plugin admin capability.
|
|
*
|
|
* Delegates to TMDO_Capability::current_user_can_admin() which additionally
|
|
* allows super admins on Multisite (v2.14.0). Method name retained for
|
|
* back-compat with the existing 30 REST endpoint registrations.
|
|
*
|
|
* @return bool True if the current user can manage WP Data Optimizer.
|
|
*/
|
|
public function require_manage_options(): bool {
|
|
return TMDO_Capability::current_user_can_admin();
|
|
}
|
|
|
|
/**
|
|
* Visibility guard for the four public REST endpoints (v2.13.3 — fixes
|
|
* M-AUTH-1 / M-LOGIC-1).
|
|
*
|
|
* The 4 `__return_true` endpoints (get_listings / get_listing / get_stats /
|
|
* post_view) historically only checked post existence via `get_post_type()`.
|
|
* That allowed anonymous reads of draft / private / trash post fields
|
|
* (Zone A flat columns + Zone C JSON blob), and accumulated view counts
|
|
* against unpublished posts.
|
|
*
|
|
* Rule: a post is viewable to the current request if either
|
|
* (a) it is publicly viewable (`is_post_publicly_viewable` — typically
|
|
* publish status + non-private + password-protected handled by WP), or
|
|
* (b) the current user has `read_post` capability on it.
|
|
*
|
|
* @param int $post_id Post ID to check.
|
|
* @return WP_REST_Response|null Null when allowed; 404 / 403 response otherwise.
|
|
*/
|
|
private function assert_post_viewable_or_error( int $post_id ): ?WP_REST_Response {
|
|
if ( $post_id < 1 || ! get_post_type( $post_id ) ) {
|
|
return new WP_REST_Response(
|
|
array(
|
|
'code' => 'not_found',
|
|
'message' => 'Post not found.',
|
|
),
|
|
404
|
|
);
|
|
}
|
|
|
|
$publicly_viewable = function_exists( 'is_post_publicly_viewable' )
|
|
? is_post_publicly_viewable( $post_id )
|
|
: ( 'publish' === get_post_status( $post_id ) );
|
|
|
|
if ( $publicly_viewable ) {
|
|
return null;
|
|
}
|
|
|
|
if ( current_user_can( 'read_post', $post_id ) ) {
|
|
return null;
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'code' => 'rest_forbidden',
|
|
'message' => 'Post not viewable.',
|
|
),
|
|
403
|
|
);
|
|
}
|
|
|
|
// ── Entity Bridge Handlers (v2.6.6) ──────────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/entity-bridge/health
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function entity_bridge_health_all( WP_REST_Request $request ): WP_REST_Response { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
|
|
if ( ! class_exists( 'TMDO_Entity_Health' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Entity_Health::get_all(), 200 );
|
|
}
|
|
|
|
/**
|
|
* GET /wpdo/v1/entity-bridge/health/{type}
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function entity_bridge_health_one( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Entity_Health' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Entity health class unavailable' ), 500 );
|
|
}
|
|
$type = (string) $request->get_param( 'type' );
|
|
return new WP_REST_Response( TMDO_Entity_Health::get_one( $type ), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/entity-bridge/backfill
|
|
*
|
|
* Schedules async cron-driven backfill for one (entity_type, group_name) pair.
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function entity_bridge_start_backfill( WP_REST_Request $request ): WP_REST_Response {
|
|
$entity_type = (string) $request->get_param( 'entity_type' );
|
|
$group_name = (string) $request->get_param( 'group_name' );
|
|
|
|
if ( ! class_exists( 'TMDO_Entity_Registry' ) || ! TMDO_Entity_Registry::get_adapter( $entity_type ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Unknown entity type' ), 400 );
|
|
}
|
|
|
|
$groups = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_groups_for_type( $entity_type ) : array();
|
|
if ( ! in_array( $group_name, $groups, true ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Unknown group for this entity type' ), 400 );
|
|
}
|
|
|
|
$hook = 'wpdo_entity_backfill_batch';
|
|
$args = array( $entity_type, $group_name );
|
|
|
|
// Clear existing scheduled event for this pair before scheduling fresh.
|
|
$existing = wp_next_scheduled( $hook, $args );
|
|
if ( $existing ) {
|
|
wp_unschedule_event( $existing, $hook, $args );
|
|
}
|
|
|
|
// Also reset the checkpoint so backfill starts from the beginning.
|
|
if ( class_exists( 'TMDO_Entity_Migration_Engine' ) ) {
|
|
TMDO_Entity_Migration_Engine::reset_checkpoint( $entity_type, $group_name );
|
|
}
|
|
|
|
wp_schedule_single_event( time(), $hook, $args );
|
|
|
|
TMDO_Logger::info(
|
|
'entity_backfill_scheduled',
|
|
array(
|
|
'entity_type' => $entity_type,
|
|
'group_name' => $group_name,
|
|
'user_id' => get_current_user_id(),
|
|
)
|
|
);
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'message' => "Backfill scheduled for {$entity_type}/{$group_name}",
|
|
'entity_type' => $entity_type,
|
|
'group_name' => $group_name,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/entity-bridge/promote
|
|
*
|
|
* Advance entity mode by one step (disabled→dual_write→shadow_read→aeav_only).
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function entity_bridge_promote( WP_REST_Request $request ): WP_REST_Response {
|
|
$entity_type = (string) $request->get_param( 'entity_type' );
|
|
|
|
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 );
|
|
}
|
|
|
|
$current = TMDO_Mode_Manager::get( $entity_type );
|
|
$order = TMDO_Mode_Manager::ALL_MODES;
|
|
$idx = array_search( $current, $order, true );
|
|
|
|
if ( false === $idx || $idx >= count( $order ) - 1 ) {
|
|
return new WP_REST_Response( array( 'error' => "Already at maximum mode: {$current}" ), 400 );
|
|
}
|
|
|
|
$next = $order[ $idx + 1 ];
|
|
$result = TMDO_Mode_Manager::set( $entity_type, $next );
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 );
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'entity_type' => $entity_type,
|
|
'from' => $current,
|
|
'to' => $next,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/entity-bridge/demote
|
|
*
|
|
* Roll back entity mode by one step.
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function entity_bridge_demote( WP_REST_Request $request ): WP_REST_Response {
|
|
$entity_type = (string) $request->get_param( 'entity_type' );
|
|
|
|
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Mode manager unavailable' ), 500 );
|
|
}
|
|
|
|
$current = TMDO_Mode_Manager::get( $entity_type );
|
|
$order = TMDO_Mode_Manager::ALL_MODES;
|
|
$idx = array_search( $current, $order, true );
|
|
|
|
if ( false === $idx || $idx <= 0 ) {
|
|
return new WP_REST_Response( array( 'error' => "Already at minimum mode: {$current}" ), 400 );
|
|
}
|
|
|
|
$prev = $order[ $idx - 1 ];
|
|
$result = TMDO_Mode_Manager::set( $entity_type, $prev );
|
|
|
|
if ( is_wp_error( $result ) ) {
|
|
return new WP_REST_Response( array( 'error' => $result->get_error_message() ), 400 );
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'entity_type' => $entity_type,
|
|
'from' => $current,
|
|
'to' => $prev,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
// ── v2.6.7: User Stress Test Handlers ────────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/stress-test/status
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function stress_test_status( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_User_Stress_Tester::get_progress(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/stress-test/start
|
|
*
|
|
* @param WP_REST_Request $request REST request with target / mode / batch_size.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function stress_test_start( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
|
|
}
|
|
$target = (int) $request->get_param( 'target' );
|
|
$mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
|
|
$batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_User_Stress_Tester::DEFAULT_BATCH_SIZE );
|
|
|
|
$result = TMDO_User_Stress_Tester::start( $target, $mode, $batch_size );
|
|
$status = ! empty( $result['ok'] ) ? 200 : 409;
|
|
return new WP_REST_Response( $result, $status );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/stress-test/cancel
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_User_Stress_Tester::cancel(), 200 );
|
|
}
|
|
|
|
/**
|
|
* DELETE /wpdo/v1/stress-test/cleanup
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_User_Stress_Tester::cleanup(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/stress-test/benchmark — re-run benchmark on current dataset.
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_User_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Stress tester unavailable' ), 500 );
|
|
}
|
|
$report = TMDO_User_Stress_Tester::run_benchmark();
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'benchmark' => $report,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
// ── v2.11.4: Post Stress Test Handlers ────────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/post-stress-test/status
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function post_stress_test_status( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Post_Stress_Tester::get_progress(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/post-stress-test/start
|
|
*
|
|
* @param WP_REST_Request $request REST request with post_type / target / mode / batch_size.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function post_stress_test_start( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
|
|
}
|
|
$post_type = (string) $request->get_param( 'post_type' );
|
|
$target = (int) $request->get_param( 'target' );
|
|
$mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
|
|
$batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Post_Stress_Tester::DEFAULT_BATCH_SIZE );
|
|
|
|
$result = TMDO_Post_Stress_Tester::start( $post_type, $target, $mode, $batch_size );
|
|
$status = ! empty( $result['ok'] ) ? 200 : 409;
|
|
return new WP_REST_Response( $result, $status );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/post-stress-test/cancel
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function post_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Post_Stress_Tester::cancel(), 200 );
|
|
}
|
|
|
|
/**
|
|
* DELETE /wpdo/v1/post-stress-test/cleanup
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function post_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
|
|
}
|
|
$result = TMDO_Post_Stress_Tester::cleanup();
|
|
// Also wipe the persisted run state so the UI re-renders idle after cleanup.
|
|
delete_option( TMDO_Post_Stress_Tester::OPT_STATE );
|
|
delete_transient( TMDO_Post_Stress_Tester::CANCEL_FLAG );
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'deleted' => (int) ( $result['deleted_posts'] ?? 0 ),
|
|
'detail' => $result,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/post-stress-test/benchmark — re-run benchmark on current state.
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function post_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Post_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Post stress tester unavailable' ), 500 );
|
|
}
|
|
$report = TMDO_Post_Stress_Tester::run_benchmark();
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'benchmark' => $report,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
// ── v2.13.0: Term Stress Test Handlers ────────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/term-stress-test/status
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function term_stress_test_status( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Term_Stress_Tester::get_progress(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/term-stress-test/start
|
|
*
|
|
* @param WP_REST_Request $request REST request with taxonomy / target / mode / batch_size.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function term_stress_test_start( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
|
|
}
|
|
$taxonomy = (string) $request->get_param( 'taxonomy' );
|
|
$target = (int) $request->get_param( 'target' );
|
|
$mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
|
|
$batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Term_Stress_Tester::DEFAULT_BATCH_SIZE );
|
|
|
|
$result = TMDO_Term_Stress_Tester::start( $taxonomy, $target, $mode, $batch_size );
|
|
$status = ! empty( $result['ok'] ) ? 200 : 409;
|
|
return new WP_REST_Response( $result, $status );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/term-stress-test/cancel
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function term_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Term_Stress_Tester::cancel(), 200 );
|
|
}
|
|
|
|
/**
|
|
* DELETE /wpdo/v1/term-stress-test/cleanup
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function term_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
|
|
}
|
|
$result = TMDO_Term_Stress_Tester::cleanup();
|
|
delete_option( TMDO_Term_Stress_Tester::OPT_STATE );
|
|
delete_transient( TMDO_Term_Stress_Tester::CANCEL_FLAG );
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'deleted' => (int) ( $result['deleted_terms'] ?? 0 ),
|
|
'detail' => $result,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/term-stress-test/benchmark
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function term_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Term_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Term stress tester unavailable' ), 500 );
|
|
}
|
|
$report = TMDO_Term_Stress_Tester::run_benchmark();
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'benchmark' => $report,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
// ── v2.13.1: Comment Stress Test Handlers ─────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/comment-stress-test/status
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function comment_stress_test_status( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Comment_Stress_Tester::get_progress(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/comment-stress-test/start
|
|
*
|
|
* @param WP_REST_Request $request REST request with post_id / target / mode / batch_size.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function comment_stress_test_start( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
|
|
}
|
|
$post_id = (int) $request->get_param( 'post_id' );
|
|
$target = (int) $request->get_param( 'target' );
|
|
$mode = (string) ( $request->get_param( 'mode' ) ?? 'fast' );
|
|
$batch_size = (int) ( $request->get_param( 'batch_size' ) ?? TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE );
|
|
|
|
$result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch_size );
|
|
$status = ! empty( $result['ok'] ) ? 200 : 409;
|
|
return new WP_REST_Response( $result, $status );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/comment-stress-test/cancel
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function comment_stress_test_cancel( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
|
|
}
|
|
return new WP_REST_Response( TMDO_Comment_Stress_Tester::cancel(), 200 );
|
|
}
|
|
|
|
/**
|
|
* DELETE /wpdo/v1/comment-stress-test/cleanup
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function comment_stress_test_cleanup( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
|
|
}
|
|
$result = TMDO_Comment_Stress_Tester::cleanup();
|
|
delete_option( TMDO_Comment_Stress_Tester::OPT_STATE );
|
|
delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG );
|
|
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'deleted' => (int) ( $result['deleted_comments'] ?? 0 ),
|
|
'detail' => $result,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/comment-stress-test/benchmark
|
|
*
|
|
* @param WP_REST_Request $request REST request (unused).
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
public function comment_stress_test_run_benchmark( WP_REST_Request $request ): WP_REST_Response {
|
|
if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
|
|
return new WP_REST_Response( array( 'error' => 'Comment stress tester unavailable' ), 500 );
|
|
}
|
|
$report = TMDO_Comment_Stress_Tester::run_benchmark();
|
|
return new WP_REST_Response(
|
|
array(
|
|
'ok' => true,
|
|
'benchmark' => $report,
|
|
),
|
|
200
|
|
);
|
|
}
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Query Zone A flat table directly.
|
|
*
|
|
* @param string $post_type Post type slug.
|
|
* @param int $per_page Number of items per page.
|
|
* @param int $offset Database offset.
|
|
* @param string $orderby Column to order by.
|
|
* @param string $order Order direction (ASC or DESC).
|
|
* @param WP_REST_Request $request REST request object.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
private function listings_from_zone_a(
|
|
string $post_type,
|
|
int $per_page,
|
|
int $offset,
|
|
string $orderby,
|
|
string $order,
|
|
WP_REST_Request $request
|
|
): WP_REST_Response {
|
|
global $wpdb;
|
|
|
|
$table = TMDO_Zone_Hot::table( $post_type );
|
|
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
|
|
|
// Build WHERE clauses from numeric filter params.
|
|
$where = array();
|
|
$params = array();
|
|
|
|
foreach ( $this->get_filter_params( $request, $columns ) as $filter ) {
|
|
$where[] = $filter['sql'];
|
|
$params[] = $filter['value'];
|
|
}
|
|
|
|
$where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : '';
|
|
|
|
// Validate orderby against known columns + post_id.
|
|
$allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) );
|
|
$orderby_col = in_array( $orderby, $allowed_orderby, true ) ? sanitize_key( $orderby ) : 'post_id';
|
|
$order_dir = 'ASC' === $order ? 'ASC' : 'DESC';
|
|
|
|
// Count total.
|
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated.
|
|
if ( $params ) {
|
|
$total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) );
|
|
} else {
|
|
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
|
}
|
|
|
|
// Fetch rows.
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d",
|
|
...array_merge( $params, array( $per_page, $offset ) )
|
|
),
|
|
ARRAY_A
|
|
) ?: array();
|
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber
|
|
|
|
// Rename post_id → id, drop internal columns, and restrict to show_in_rest fields.
|
|
$visible_cols = array_fill_keys(
|
|
TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ),
|
|
true
|
|
);
|
|
$items = array_map(
|
|
function ( array $row ) use ( $post_type, $visible_cols ): array {
|
|
$item = array(
|
|
'id' => (int) $row['post_id'],
|
|
'post_type' => $post_type,
|
|
);
|
|
unset( $row['post_id'], $row['updated_at'] );
|
|
// Keep only columns with show_in_rest => true (or all if no registry entry).
|
|
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
|
|
return array_merge( $item, $row );
|
|
},
|
|
$rows
|
|
);
|
|
|
|
$response = new WP_REST_Response( $items, 200 );
|
|
$response->header( 'X-WP-Total', (string) $total );
|
|
$response->header( 'X-WP-TotalPages', (string) (int) ceil( $total / $per_page ) );
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Fall back to WP_Query when zone is not cutover.
|
|
*
|
|
* @param string $post_type Post type slug.
|
|
* @param int $per_page Number of items per page.
|
|
* @param int $page Page number.
|
|
* @param WP_REST_Request $request REST request object. Not used directly.
|
|
* @return WP_REST_Response REST response.
|
|
*/
|
|
private function listings_from_wp_query( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $request kept for potential future filtering.
|
|
string $post_type,
|
|
int $per_page,
|
|
int $page,
|
|
WP_REST_Request $request
|
|
): WP_REST_Response {
|
|
$args = array(
|
|
'post_type' => $post_type,
|
|
'posts_per_page' => $per_page,
|
|
'paged' => $page,
|
|
'post_status' => 'publish',
|
|
);
|
|
|
|
$query = new WP_Query( $args );
|
|
$items = array();
|
|
|
|
foreach ( $query->posts as $post ) {
|
|
$item = array(
|
|
'id' => $post->ID,
|
|
'post_type' => $post->post_type,
|
|
);
|
|
$cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
|
foreach ( array_keys( $cols ) as $col ) {
|
|
$item[ $col ] = get_post_meta( $post->ID, $col, true );
|
|
}
|
|
$items[] = $item;
|
|
}
|
|
|
|
$response = new WP_REST_Response( $items, 200 );
|
|
$response->header( 'X-WP-Total', (string) $query->found_posts );
|
|
$response->header( 'X-WP-TotalPages', (string) $query->max_num_pages );
|
|
|
|
return $response;
|
|
}
|
|
|
|
/**
|
|
* Extract numeric filter params (e.g. hp_price_min => hp_price >= ?)
|
|
* from the request, validated against registered hot columns.
|
|
*
|
|
* @param WP_REST_Request $request REST request object.
|
|
* @param array $columns Registered hot columns from Schema Registry.
|
|
* @return array[] Each element: ['sql' => string, 'value' => mixed].
|
|
*/
|
|
private function get_filter_params( WP_REST_Request $request, array $columns ): array {
|
|
$filters = array();
|
|
|
|
foreach ( array_keys( $columns ) as $col ) {
|
|
$col = sanitize_key( $col );
|
|
|
|
$min = $request->get_param( $col . '_min' );
|
|
if ( null !== $min && is_numeric( $min ) ) {
|
|
$filters[] = array(
|
|
'sql' => "`{$col}` >= %f",
|
|
'value' => (float) $min,
|
|
);
|
|
}
|
|
|
|
$max = $request->get_param( $col . '_max' );
|
|
if ( null !== $max && is_numeric( $max ) ) {
|
|
$filters[] = array(
|
|
'sql' => "`{$col}` <= %f",
|
|
'value' => (float) $max,
|
|
);
|
|
}
|
|
|
|
$exact = $request->get_param( $col );
|
|
if ( null !== $exact && '' !== $exact ) {
|
|
if ( is_numeric( $exact ) ) {
|
|
$filters[] = array(
|
|
'sql' => "`{$col}` = %f",
|
|
'value' => (float) $exact,
|
|
);
|
|
} else {
|
|
$filters[] = array(
|
|
'sql' => "`{$col}` = %s",
|
|
'value' => (string) $exact,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $filters;
|
|
}
|
|
|
|
/**
|
|
* Argument definitions for GET /listings.
|
|
*/
|
|
private function listings_args(): array {
|
|
return array(
|
|
'post_type' => array(
|
|
'default' => 'hp_listing',
|
|
'sanitize_callback' => 'sanitize_key',
|
|
),
|
|
'per_page' => array(
|
|
'default' => 20,
|
|
'sanitize_callback' => 'absint',
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1 && (int) $v <= 100,
|
|
),
|
|
'page' => array(
|
|
'default' => 1,
|
|
'sanitize_callback' => 'absint',
|
|
'validate_callback' => fn( $v ) => is_numeric( $v ) && (int) $v >= 1,
|
|
),
|
|
'orderby' => array(
|
|
'default' => 'post_id',
|
|
'sanitize_callback' => 'sanitize_key',
|
|
),
|
|
'order' => array(
|
|
'default' => 'DESC',
|
|
'sanitize_callback' => 'sanitize_text_field',
|
|
'validate_callback' => fn( $v ) => in_array( strtoupper( $v ), array( 'ASC', 'DESC' ), true ),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Migration Wizard handlers (v2.8.0) ────────────────────────────────────
|
|
|
|
/**
|
|
* GET /wpdo/v1/migration/preflight
|
|
*
|
|
* Read-only diagnostic — returns current ratio, mode, group residue, and
|
|
* the strategy `start()` would pick.
|
|
*
|
|
* @param WP_REST_Request $request Unused (read-only endpoint takes no params).
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function migration_preflight( WP_REST_Request $request ): WP_REST_Response {
|
|
unset( $request );
|
|
return new WP_REST_Response( TMDO_Migration_Orchestrator::preflight(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/migration/start
|
|
*
|
|
* Body params: verify_strict (bool, default true), verify_24h (bool),
|
|
* auto_backup (bool, default true), force_async (bool), dry_run (bool).
|
|
*
|
|
* @param WP_REST_Request $request Body parameters.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function migration_start( WP_REST_Request $request ): WP_REST_Response {
|
|
$options = array(
|
|
'verify_strict' => null === $request->get_param( 'verify_strict' ) ? true : (bool) $request->get_param( 'verify_strict' ),
|
|
'verify_24h' => (bool) $request->get_param( 'verify_24h' ),
|
|
'auto_backup' => null === $request->get_param( 'auto_backup' ) ? true : (bool) $request->get_param( 'auto_backup' ),
|
|
'force_async' => (bool) $request->get_param( 'force_async' ),
|
|
'dry_run' => (bool) $request->get_param( 'dry_run' ),
|
|
);
|
|
$result = TMDO_Migration_Orchestrator::start( $options );
|
|
$status = ! empty( $result['ok'] ) ? 200 : ( 'nothing_to_do' === ( $result['reason'] ?? '' ) ? 200 : 409 );
|
|
return new WP_REST_Response( $result, $status );
|
|
}
|
|
|
|
/**
|
|
* GET /wpdo/v1/migration/status — polling target.
|
|
*
|
|
* @param WP_REST_Request $request Unused.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function migration_status( WP_REST_Request $request ): WP_REST_Response {
|
|
unset( $request );
|
|
return new WP_REST_Response( TMDO_Migration_Orchestrator::get_status(), 200 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/migration/cancel
|
|
*
|
|
* @param WP_REST_Request $request Unused.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function migration_cancel( WP_REST_Request $request ): WP_REST_Response {
|
|
unset( $request );
|
|
$ok = TMDO_Migration_Orchestrator::cancel();
|
|
return new WP_REST_Response( array( 'ok' => $ok ), $ok ? 200 : 409 );
|
|
}
|
|
|
|
/**
|
|
* POST /wpdo/v1/migration/resume
|
|
*
|
|
* @param WP_REST_Request $request Unused.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function migration_resume( WP_REST_Request $request ): WP_REST_Response {
|
|
unset( $request );
|
|
$result = TMDO_Migration_Orchestrator::resume();
|
|
return new WP_REST_Response( $result, ! empty( $result['ok'] ) ? 200 : 409 );
|
|
}
|
|
}
|