Files
2meet-data-optimizer/includes/class-tmdo-rest-api.php
T
wpdev 6751c69bc2 fix(boundary): 核心不得無守衛呼叫 AddOn 的 TMDO_Listing_Stats
實機渲染時 Dashboard 分頁 fatal:"Class TMDO_Listing_Stats not found"。
該類別住在 hivepress-addon,核心有 6 處直呼,沒裝 AddOn 的站台會炸掉
Dashboard 與兩個 REST 端點(GET /listing/{id}、POST /listing/{id}/view)。

- TMDO_Zone_Warm 新增 VIEW_KEY / VIEW_TTL 常數(值與 AddOn 的
  TMDO_Listing_Stats::VIEW_KEY 完全相同的 'wpdo_views',指向同一批列,
  無資料遷移)
- CLI benchmark 改用核心常數
- REST 兩個 handler 改走新的 read_view_count() / bump_view_count():
  AddOn 在場時仍委派過去(保留 hp_view_count postmeta fallback),
  否則核心自己讀寫 warm 列
- wp tmdo cleanup --archive-expired 加守衛,AddOn 缺席時印 warning 並跳過
2026-07-31 10:34:40 +08:00

1679 lines
57 KiB
PHP

<?php
/**
* REST API endpoints for WP Data Optimizer.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
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 = TMDO_Zone_Router::module_name( 'hot', $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 = TMDO_Zone_Router::module_name( 'hot', $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 = TMDO_Zone_Router::module_name( 'cold', $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 = self::read_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 );
self::bump_view_count( $post_id );
$views = self::read_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 );
}
/**
* Read a post's view counter.
*
* Delegates to the HivePress AddOn when it is active, because that class
* adds an hp_view_count postmeta fallback for listings migrated before the
* warm zone existed. Without the AddOn — the normal case for a plain
* install — core reads its own warm row directly. Calling the AddOn class
* unconditionally used to fatal these endpoints on any site without it.
*
* @param int $post_id Post ID.
* @return int
*/
private static function read_view_count( int $post_id ): int {
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
return (int) TMDO_Listing_Stats::get_view_count( $post_id );
}
return (int) ( TMDO_Zone_Warm::get( $post_id, TMDO_Zone_Warm::VIEW_KEY ) ?? 0 );
}
/**
* Increment a post's view counter.
*
* @param int $post_id Post ID.
* @return void
*/
private static function bump_view_count( int $post_id ): void {
if ( class_exists( 'TMDO_Listing_Stats' ) ) {
TMDO_Listing_Stats::increment_view( $post_id );
return;
}
TMDO_Zone_Warm::increment( $post_id, TMDO_Zone_Warm::VIEW_KEY, 1, TMDO_Zone_Warm::VIEW_TTL );
}
}