connection = $connection; $this->logger = $logger; $this->seo = new Seo_Detector(); $this->abilities = $abilities; $this->cache_purger = $cache_purger; $this->builder_content = new Builder_Content( $logger ); } public function register_routes(): void { register_rest_route( 'wppilot/v1', '/auth/handshake', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_handshake' ), 'permission_callback' => array( $this, 'handshake_permission_check' ), ) ); register_rest_route( 'wppilot/v1', '/heartbeat', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_heartbeat' ), 'permission_callback' => array( $this, 'heartbeat_permission_check' ), ) ); register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/meta', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_meta' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_meta' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), 'seo_plugin' => array( 'required' => true, ), ), ), ) ); // Post title + excerpt endpoints (SPA Foundation PR). Same callback-token // auth as meta; same GET (read current) / POST (write new) shape. A single // pair of handlers serves both fields, keyed by the {field} path segment. foreach ( array( 'title', 'excerpt' ) as $field ) { register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/' . $field, array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_content_field' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_content_field' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), ), ), ) ); } // 1.5.17: the title search engines show. GET says where it comes from on // this site (an SEO plugin's field, the site title and tagline, or the // post's name) and its current value; POST writes it only where it is // the SEO plugin's own field. Same callback-token auth and before/after // write contract as the title/excerpt fields. register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/seo-title', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_seo_title' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_seo_title' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), ), ), ) ); // Post status endpoint (set_post_status action). GET reads the current // status; POST writes a new reversible status (publish/draft/private/ // trash). Same before/after write contract as the title/excerpt fields. register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/status', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_status' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_status' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), ), ), ) ); // Create a new draft post or page (create_content_object / create_draft_page // action). POST only; creates private draft and returns the new post ID plus // native preview/edit links. Publishing is a separate set_post_status call. register_rest_route( 'wppilot/v1', '/posts', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_create_draft' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'post_type' => array( 'required' => true, 'validate_callback' => static function ( $value ): bool { $object = get_post_type_object( sanitize_key( (string) $value ) ); return $object instanceof \WP_Post_Type && ! empty( $object->public ) && ! empty( $object->show_ui ) && '' !== trim( (string) $object->label ); }, ), 'title' => array( 'required' => true, ), 'content' => array( 'required' => true, ), 'excerpt' => array( 'required' => false, ), // Optional Elementor element tree (JSON). When present the page is // created as an Elementor page; see handle_create_draft(). 'elementor_data' => array( 'required' => false, 'validate_callback' => static fn( $value ): bool => is_string( $value ), ), ), ) ); // Post content (body) endpoint (edit_post_content action). GET returns the // raw post_content plus the editor-type signals the backend's safety checks // need (block delimiters, the pagebuilder detected on this post, and any // builder plugins active site-wide). POST writes the new body after structure- // aware validation; existing untouched markup is preserved byte-for-byte. register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/content', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_post_content' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_post_content' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), /* * Compare-and-swap, optional for compatibility. * * The backend serializes exactly one rollback write per * change, but "the caller only sends one" is a property * of the caller, not of this endpoint. Without a guard * here, two requests that both believed they were the * only one would both be applied, and the second would * overwrite whatever the first produced. * * When present, the write happens only if the stored * content still hashes to this value — the same rule * `/builder-content` has always enforced. Optional so a * backend older than this plugin keeps working; the * current backend always sends it. */ 'expected_state_hash' => array( 'required' => false, ), ), ), ) ); // Elementor page data — GET returns the raw `_elementor_data` JSON string // (Elementor stores the whole page as a JSON tree in postmeta, ignoring // post_content), POST writes a new validated tree back and invalidates the // cached page CSS so it regenerates. Same before/after write contract as // /content so the backend's snapshot/rollback machinery is unchanged. register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/elementor', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_elementor_data' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_elementor_data' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), // 1.5.24: written only while the stored layout still hashes to this. 'expected_state_hash' => array( 'required' => false, ), ), ), ) ); // POST /elementor/regenerate — rebuild Elementor's generated CSS and its // locally stored Google Fonts (1.5.4). Used after WPPilot moves a site's // stored addresses off a staging copy: the fonts Elementor downloaded // while the site lived there are otherwise served from that copy forever. register_rest_route( 'wppilot/v1', '/elementor/regenerate', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_regenerate_elementor' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // Structured builder content — pages whose text lives in a builder's own // serialized storage rather than post_content or an Elementor JSON tree // (Beaver Builder, Bricks). GET returns the normalized editable text // leaves plus the complete state and its hash; POST applies ONE addressed // leaf and refuses unless the result matches the approved state exactly. // The same POST performs a rollback, with the two texts swapped, so there // is one write path and one set of guards rather than two. register_rest_route( 'wppilot/v1', '/posts/(?P\\d+)/builder-content', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_builder_content' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_builder_content' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'node_id' => array( 'required' => true ), 'expected_text' => array( 'required' => true ), 'new_text' => array( 'required' => true ), 'expected_state_hash' => array( 'required' => true ), 'result_state_hash' => array( 'required' => false ), 'expected_draft_state_hash' => array( 'required' => false ), 'result_draft_state_hash' => array( 'required' => false ), 'dry_run' => array( 'required' => false ), ), ), ) ); // Media sideload — copies an approved stock photograph into this site's // own media library so published content owns its images instead of // hotlinking a third-party CDN for ever. Only ever called after the owner // has approved the content the image belongs to. register_rest_route( 'wppilot/v1', '/media/sideload', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_sideload_media' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'url' => array( 'required' => true ), 'alt' => array( 'required' => false ), ), ) ); // Image alt text — GET returns the attachment's current alt text plus the // public source URL (the backend fetches the image bytes from there for the // vision model), POST writes the new alt text back to the standard // `_wp_attachment_image_alt` postmeta. Same before/after write contract as // /content so the backend's snapshot/rollback machinery is unchanged. register_rest_route( 'wppilot/v1', '/media/(?P\d+)/alt-text', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_alt_text' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_write_alt_text' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'value' => array( 'required' => true, ), ), ), ) ); // Passive discovery: which WPPilot add-ons announced themselves through // the `wppilot_advanced_modules` filter. Lite installs, activates and // verifies nothing, and grants no authority by reporting. register_rest_route( 'wppilot/v1', '/core/status', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_core_status' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // Site model — one round trip with everything the backend's // site-model-builder needs to assemble the Persistent Site Model // (identity, counts, a recent content sample, plugin inventory, theme, // abilities registry, site permissions and the graceful-degradation flag). register_rest_route( 'wppilot/v1', '/site-model', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_site_model' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); /* ---- Layer 3 (On-Demand Retrieval) read-only endpoints ----------- * * The backend's Layer 3 tools query current site state mid-conversation * (brief Part 9). Every route below is READ-ONLY — no writes, honoring * the Lite/Core boundary (brief Part 7.5): detection/reads in Lite, * writes only through the action library's preview/execute/rollback. * All use the same callback-token auth (WP.org Rule 15: real check). */ // GET /post?id= | ?slug= — full current content of a single post. register_rest_route( 'wppilot/v1', '/post', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_post' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // GET /posts?type=&limit=&order_by=&status=&after= — list current posts. // `after` walks the whole set by keyset cursor; see handle_list_posts. register_rest_route( 'wppilot/v1', '/posts', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_list_posts' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // GET /search?query=&post_types=&limit= — lexical content search. register_rest_route( 'wppilot/v1', '/search', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_search' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // GET /site-inventory — fresh environment + plugin inventory signal. register_rest_route( 'wppilot/v1', '/site-inventory', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_site_inventory' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // GET /theme-style — the active theme's design tokens and block support, // so generated content can be composed in the site's own palette and // typography instead of an invented one. register_rest_route( 'wppilot/v1', '/theme-style', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_theme_style' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // GET /site-structure — post types, taxonomies, page tree, menus. register_rest_route( 'wppilot/v1', '/site-structure', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_site_structure' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // Menu editing reads and writes one existing item at a time. The backend // supplies fingerprints captured during preview so a stale or moved item // is rejected rather than silently edited in a new structural context. register_rest_route( 'wppilot/v1', '/menus', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_list_menus' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); register_rest_route( 'wppilot/v1', '/menus/(?P\d+)', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_menu' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'menu_id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ) ); register_rest_route( 'wppilot/v1', '/menus/(?P\d+)/items/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_menu_item' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'handle_update_menu_item' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ), 'args' => array( 'menu_id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), 'item_id' => array( 'validate_callback' => static function ( $value ): bool { return is_numeric( $value ) && (int) $value > 0; }, ), ), ) ); // GET /cron-events — scheduled WP-Cron events (basic listing). register_rest_route( 'wppilot/v1', '/cron-events', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_cron_events' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); /* ---- Watchtower scan data endpoints (PR #24) --------------------- * * Three read-only endpoints for the backend's Watchtower scan engine. * * The plugin is a thin data layer: enumerate files + return content. * * Engines run on the backend in TypeScript. Lite-eligible (no writes).* * All paginated to stay under 10s per request on shared hosts. */ // GET /scan/file-hashes — paginated file hash enumeration. register_rest_route( 'wppilot/v1', '/scan/file-hashes', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_scan_file_hashes' ), 'permission_callback' => array( $this, 'scan_permission_check' ), ) ); // POST /scan/file-contents — return content for requested file paths. register_rest_route( 'wppilot/v1', '/scan/file-contents', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_scan_file_contents' ), 'permission_callback' => array( $this, 'scan_permission_check' ), ) ); /* ---- General reads (1.4.0) ------------------------------------------ * * Two read-only routes that let the service reason over a post's own * * stored state and the site's environment without a per-builder or * * per-plugin adapter. Both are object- or site-scoped facts an admin * * already sees in wp-admin; neither executes anything, escapes the * * object, or writes. Credential-shaped keys and values are redacted on * * the site before the response is built (Scan_Policy::redact_value). */ // GET /posts/{id}/raw — the post row and every postmeta row, redacted and bounded. register_rest_route( 'wppilot/v1', '/posts/(?P\d+)/raw', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_get_post_raw' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), 'args' => array( 'id' => array( 'validate_callback' => static fn( $value ) => is_numeric( $value ), 'sanitize_callback' => 'absint', ), // Optional: only these meta keys, so a caller can ask for the one // builder tree it needs instead of a megabyte it will have to cut. 'keys' => array( 'validate_callback' => static fn( $value ) => is_string( $value ) && strlen( $value ) <= 2048, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); // GET /environment — the facts Site Health shows an administrator. register_rest_route( 'wppilot/v1', '/environment', array( 'methods' => 'GET', 'callback' => array( $this, 'handle_environment' ), 'permission_callback' => array( $this, 'callback_token_permission_check' ), ) ); // There is deliberately no database-content endpoint. The one that // existed returned every autoloaded option's raw value — which is where // other plugins keep their API keys and SMTP passwords — and nothing in // the service ever called it. Removing it deletes that exposure outright // rather than trying to guess which option names hold secrets, which is // unknowable: a credential can live in a nested field of an option whose // own name reveals nothing. } /** * Map a {field} path segment to the wp_posts column it reads/writes. Only * 'title' and 'excerpt' are routed here, so the default is defensive. */ private function field_segment( \WP_REST_Request $request ): string { $route = $request->get_route(); if ( str_ends_with( $route, '/excerpt' ) ) { return 'excerpt'; } return 'title'; } /** * Handshake is only open during an active connection window and requires * the caller to present the one-time connect nonce that was issued when the * admin clicked "Connect." The nonce is consumed here (single-use) so even * during the short window only the caller that received the nonce from the * connect request can complete the handshake. */ public function handshake_permission_check( \WP_REST_Request $request ): bool|\WP_Error { $stored_nonce = get_transient( 'wppilot_connect_window' ); if ( ! $stored_nonce || '1' === $stored_nonce ) { return new \WP_Error( 'rest_forbidden', 'No connection in progress.', array( 'status' => 403 ) ); } // Consume immediately — single-use at the permission layer. delete_transient( 'wppilot_connect_window' ); $provided_nonce = sanitize_text_field( $request->get_param( 'connect_nonce' ) ?? '' ); if ( empty( $provided_nonce ) || ! hash_equals( $stored_nonce, $provided_nonce ) ) { return new \WP_Error( 'rest_forbidden', 'Invalid connect nonce.', array( 'status' => 403 ) ); } return true; } /** * Heartbeat is authenticated via the backend's callback token, verified in * constant time against the stored hash. This is a real permission check — * not __return_true (WP.org Rule 15). */ public function heartbeat_permission_check( \WP_REST_Request $request ): bool|\WP_Error { return $this->callback_token_permission_check( $request ); } /** * The one refusal that means "this site no longer knows WPPilot". * * WordPress answers 403 both for an unrecognised caller and for a * recognised caller who may not perform an operation, and the two need * opposite responses: one is fixed by reconnecting, the other must never * suggest it. Only this code is emitted by the callback-token check, so the * backend can tell them apart instead of guessing from the status. * * @param string $message Human-readable detail. * @return \WP_Error */ private function callback_auth_error( string $message ): \WP_Error { return new \WP_Error( 'wppilot_callback_auth', $message, array( 'status' => 403 ) ); } /** * Verify the backend's per-site callback token (Bearer), constant-time against * the stored hash. Shared by the heartbeat and the meta endpoints — a real * permission check, never __return_true (WP.org Rule 15). */ public function callback_token_permission_check( \WP_REST_Request $request ): bool|\WP_Error { $auth_header = $request->get_header( 'authorization' ); if ( empty( $auth_header ) || ! str_starts_with( $auth_header, 'Bearer ' ) ) { return $this->callback_auth_error( 'Missing or invalid authorization.' ); } $token = substr( $auth_header, 7 ); if ( ! $this->connection->verify_callback_token( $token ) ) { return $this->callback_auth_error( 'Invalid callback token.' ); } // The callback token authenticates the WPPilot backend. Restore the // WordPress user who completed the connection so every downstream // handler can enforce `current_user_can()` for the exact operation. $connected_user_id = $this->connection->get_connected_user_id(); if ( $connected_user_id > 0 ) { wp_set_current_user( $connected_user_id ); } return true; } /** * The security scan reads the site's files, so beyond the callback token * the connected user must be one who manages the site. */ public function scan_permission_check( \WP_REST_Request $request ): bool|\WP_Error { $allowed = $this->callback_token_permission_check( $request ); if ( true !== $allowed ) { return $allowed; } if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rest_forbidden', 'The connected user cannot scan this site.', array( 'status' => 403 ) ); } return true; } /** * Handle the connection handshake challenge. The backend sends a random * challenge string; the plugin responds with HMAC-SHA256(secret, challenge), * proving it holds the per-site secret for this URL. */ public function handle_handshake( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $challenge = sanitize_text_field( $request->get_param( 'challenge' ) ?? '' ); if ( empty( $challenge ) ) { return new \WP_Error( 'wppilot_bad_request', 'Missing challenge.', array( 'status' => 400 ) ); } $secret = $this->connection->get_secret(); if ( empty( $secret ) ) { $this->logger->error( 'Handshake attempted but no secret stored' ); return new \WP_Error( 'wppilot_no_secret', 'Plugin has no stored secret.', array( 'status' => 500 ) ); } $response_hmac = hash_hmac( 'sha256', $challenge, $secret ); $this->logger->info( 'Handshake challenge answered' ); return new \WP_REST_Response( array( 'response' => $response_hmac ), 200 ); } /** * The post a `/posts/{id}/…` route names, resolved to what it really is * before anything reads it as a post or writes it. * * 404 when there is no such post; a 409 `wppilot_storage_not_owned` * refusal when its post row is not the record ({@see Post_Storage}): an * HPOS order's placeholder post, an order of any storage, a type nothing on * the site registers. Every post-addressed read and write goes through * here, so none of them can report the placeholder's fields as the order's * or write the placeholder and call the order changed. * * @param int $post_id The id in the route. * @return \WP_Post|\WP_Error */ private function addressed_post( int $post_id ): \WP_Post|\WP_Error { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return new \WP_Error( 'wppilot_post_not_found', 'Post not found.', array( 'status' => 404 ) ); } $refusal = Post_Storage::refusal( $post ); if ( null !== $refusal ) { $this->logger->info( 'Refused a post-addressed request for post ' . $post_id . ': its storage is not a post (' . (string) $post->post_type . ')' ); return $refusal; } return $post; } /** * GET /posts/{id}/meta — return the post's current SEO meta description and the * detected SEO plugin. The backend uses post_title to compose the Claude prompt * without a second round-trip. */ public function handle_get_meta( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $plugin = $this->seo->detect(); $meta_key = $this->seo->meta_key_for( $plugin ); $current = $this->meta_value_to_string( get_post_meta( $post_id, $meta_key, true ) ) ?? ''; return new \WP_REST_Response( array( 'post_id' => $post_id, 'current_meta_description' => sanitize_text_field( $current ), 'seo_plugin_detected' => $plugin, 'post_title' => sanitize_text_field( get_the_title( $post_id ) ), // 1.5.17: an SEO plugin that prints its own description, so a // description stored here would reach no page. 'other_seo_plugin' => $this->seo->other_seo_plugin(), ), 200 ); } /** * GET /posts/{id}/seo-title — where the post's search title comes from on * this site, and its current value there (`Seo_Title::describe`). */ public function handle_get_seo_title( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } return new \WP_REST_Response( ( new Seo_Title( $this->seo ) )->describe( $post_id ), 200 ); } /** * POST /posts/{id}/seo-title — write the post's search title to its SEO * plugin's own field. Refused (409) where that field is not what the page's * title comes from. Returns the before/after values, like the title write. */ public function handle_write_seo_title( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this post.', array( 'status' => 403 ) ); } $written = ( new Seo_Title( $this->seo ) )->write( $post_id, (string) $request->get_param( 'value' ) ); if ( is_wp_error( $written ) ) { return $written; } $this->logger->info( 'Search title written for post ' . $post_id ); $cache = $this->cache_purger->purge_post_cache( $post_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $written['written_value'], 'previous_value' => $written['previous_value'], 'cache' => $cache, ), 200 ); } /** * POST /posts/{id}/meta — write a new SEO meta description. The backend passes * the value and the seo_plugin key to write to (it does not let the plugin pick * independently, so the key matches the one the read reported). Returns the * before/after values for the execute confirmation. */ public function handle_write_meta( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this post.', array( 'status' => 403 ) ); } $value = sanitize_text_field( (string) $request->get_param( 'value' ) ); $seo_plugin = sanitize_text_field( (string) $request->get_param( 'seo_plugin' ) ); if ( ! $this->seo->is_known_plugin( $seo_plugin ) ) { return new \WP_Error( 'wppilot_bad_request', 'Unknown seo_plugin.', array( 'status' => 400 ) ); } $meta_key = $this->seo->meta_key_for( $seo_plugin ); $previous = $this->meta_value_to_string( get_post_meta( $post_id, $meta_key, true ) ) ?? ''; // update_post_meta() unslashes what it is given; slash it so a backslash survives. update_post_meta( $post_id, $meta_key, wp_slash( $value ) ); $this->logger->info( 'Meta description written for post ' . $post_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $value, 'previous_value' => sanitize_text_field( $previous ), ), 200 ); } /** * GET /posts/{id}/{title|excerpt} — return the post's current title or excerpt * plus its title (for prompt composition without a second round-trip). Same * response shape as the meta read, keyed by the {field} path segment. */ public function handle_get_content_field( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $field = $this->field_segment( $request ); $value = 'excerpt' === $field ? (string) $post->post_excerpt : (string) $post->post_title; return new \WP_REST_Response( array( 'post_id' => $post_id, 'value' => sanitize_text_field( $value ), 'post_title' => sanitize_text_field( get_the_title( $post_id ) ), ), 200 ); } /** * POST /posts/{id}/{title|excerpt} — write a new title or excerpt. Returns the * before/after values for the execute confirmation, matching the meta write. */ public function handle_write_content_field( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this post.', array( 'status' => 403 ) ); } $field = $this->field_segment( $request ); $value = sanitize_text_field( (string) $request->get_param( 'value' ) ); if ( 'excerpt' === $field ) { $previous = (string) $post->post_excerpt; $update = array( 'ID' => $post_id, 'post_excerpt' => $value, ); } else { $previous = (string) $post->post_title; $update = array( 'ID' => $post_id, 'post_title' => $value, ); } // wp_update_post() expects slashed data and unslashes it: slash it so a backslash survives. $result = wp_update_post( wp_slash( $update ), true ); if ( is_wp_error( $result ) ) { $this->logger->error( 'Failed to write ' . $field . ' for post ' . $post_id ); return new \WP_Error( 'wppilot_write_failed', 'Could not update the post.', array( 'status' => 500 ) ); } $this->logger->info( ucfirst( $field ) . ' written for post ' . $post_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $value, 'previous_value' => sanitize_text_field( $previous ), ), 200 ); } /** * GET /posts/{id}/status — return the post's current publication status plus * its title. Drives the set_post_status preview/drift check. */ public function handle_get_status( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } return new \WP_REST_Response( array( 'post_id' => $post_id, 'status' => (string) $post->post_status, 'post_title' => sanitize_text_field( get_the_title( $post_id ) ), // Moving it to the trash would delete it for good (trash off). 'trash_deletes' => self::trash_deletes( $post ), ), 200 ); } /** * Whether moving this post to the trash would delete it for good: the trash * is switched off (`EMPTY_TRASH_DAYS` 0, where `wp_trash_post()` calls * `wp_delete_post( $id, true )`), or it is an attachment and the media trash * is off (`MEDIA_TRASH` false). * * @param \WP_Post $post The post. */ public static function trash_deletes( \WP_Post $post ): bool { if ( defined( 'EMPTY_TRASH_DAYS' ) && ! EMPTY_TRASH_DAYS ) { return true; } return 'attachment' === $post->post_type && ! ( defined( 'MEDIA_TRASH' ) && MEDIA_TRASH ); } /** * Move a post to `$value` the way wp-admin would, so a trip into the trash * and back out of it is exact. * * Into the trash: `wp_trash_post()`, which also trashes the post's comments * (each becomes `post-trashed`, its own status kept in the post's * `_wp_trash_meta_comments_status`). The caller refuses it first where it * would delete instead ({@see trash_deletes()}). * * Out of the trash: core's `wp_untrash_post()` steps (wp-includes/post.php, * WordPress 6.x) — the `pre_untrash_post` filter, the `untrash_post` action, * removing `_wp_trash_meta_status` / `_wp_trash_meta_time`, * `wp_untrash_post_comments()`, the `untrashed_post` action — but with the * requested status set straight from `trash`. Core's own call parks the post * in `draft` first (`wp_untrash_post_status`), and that hop fires * `draft_to_publish` as if the post were published for the first time * (Jetpack and newsletters re-share it); `trash_to_publish` is what an * undo of a trash must look like. * * Order, and why: the STATUS first, then the trash bookkeeping, then the * comments. A status write that fails leaves nothing changed — the answer * "nothing written" stays true. A comment restore that fails after it * leaves the post back and its comments still hidden, which is reported * (`comments_restored` false) rather than hidden behind a success. * * wptech.group, 2026-10-15: fifteen posts were trashed and "undone" with a * plain `wp_update_post`, which restored the posts and left every comment * on them `post-trashed` — gone from every comment screen (0 waiting, 0 * approved, 0 spam, 0 trash) while each undo reported success. * * The post's stored dates are kept (see {@see keeps_post_dates()}). * * Leaving the trash is refused, with nothing written, when another post * has taken the address it had ({@see restored_slug_conflict()}). * * @param int $post_id The post. * @param string $previous Its status now. * @param string $value The status it should have. * @return array{status_written: bool, comments_restored: bool|null, slug_taken?: string} `comments_restored` * is null when the post was not leaving the trash; `slug_taken` is the * slug another post now holds, when that refused the write. */ public static function write_post_status( int $post_id, string $previous, string $value ): array { if ( 'trash' === $previous && 'trash' !== $value ) { $taken = self::restored_slug_conflict( $post_id ); if ( null !== $taken ) { return array( 'status_written' => false, 'comments_restored' => null, 'slug_taken' => $taken, ); } } $post = get_post( $post_id ); $dates = $post instanceof \WP_Post ? array( 'post_date' => (string) $post->post_date, 'post_date_gmt' => (string) $post->post_date_gmt, ) : null; $written = self::write_post_status_only( $post_id, $previous, $value ); if ( $written['status_written'] && null !== $dates && self::keeps_post_dates( $dates['post_date_gmt'], $value ) ) { self::put_back_post_dates( $post_id, $dates ); } return $written; } /** * Whether a status change must leave the post's stored dates as they were. * * Always, except the one change WordPress dates on purpose: a post never * dated (a draft's `post_date_gmt` is zero) being published, scheduled or * made private gets its publication date. Every other date move is a side * effect of `wp_update_post()` re-saving the post: a draft with no date of * its own is re-dated to "now" on ANY save (its `clear_date` rule), so * trashing a draft moved its date and gave it a GMT one, and undoing the * trash could not put either back (Phase A undo gate, 2026-10-18). * * @param string $gmt The post's `post_date_gmt` before the change. * @param string $value The status it is given. */ public static function keeps_post_dates( string $gmt, string $value ): bool { $undated = '' === $gmt || '0000-00-00 00:00:00' === $gmt; return ! ( $undated && in_array( $value, array( 'publish', 'private', 'future' ), true ) ); } /** * Write the post's dates back as they were stored, only where the status * write moved them: the two columns of one row, then the post cache. * * @param array{post_date: string, post_date_gmt: string} $dates */ private static function put_back_post_dates( int $post_id, array $dates ): void { global $wpdb; $now = get_post( $post_id ); if ( ! $now instanceof \WP_Post || ( (string) $now->post_date === $dates['post_date'] && (string) $now->post_date_gmt === $dates['post_date_gmt'] ) ) { return; } // Two columns of one row, by design (see keeps_post_dates); clean_post_cache() follows. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $wpdb->posts, array( 'post_date' => $dates['post_date'], 'post_date_gmt' => $dates['post_date_gmt'], ), array( 'ID' => $post_id ), array( '%s', '%s' ), array( '%d' ) ); clean_post_cache( $post_id ); } /** * The slug a post leaving the trash would get back, when another post has * taken it since; null when it is free (or there is none to put back). * * Trashing renames a post `{slug}__trashed`, which frees its slug: another * post can be published at that address meanwhile. Coming back, WordPress * restores the recorded slug — and for a draft or a pending post it does * not de-duplicate it (`wp_unique_post_slug()` skips those statuses), so * two posts shared one slug; for a published one it renames it `{slug}-2`, * which is not the post as it was either. So the question is asked before * anything is written, with WordPress's own uniqueness rule as a published * post meets it, whatever status the post is going back to. */ public static function restored_slug_conflict( int $post_id ): ?string { $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post || ! metadata_exists( 'post', $post_id, '_wp_desired_post_slug' ) ) { return null; } $slug = (string) get_post_meta( $post_id, '_wp_desired_post_slug', true ); if ( '' === $slug ) { return null; } $unique = wp_unique_post_slug( $slug, $post_id, 'publish', (string) $post->post_type, (int) ( $post->post_parent ?? 0 ) ); return $unique === $slug ? null : $slug; } /** * The slug the post had before it was trashed, put back from the record * WordPress itself keeps of it. * * Trashing renames the post `{slug}__trashed` and stores the slug in * `_wp_desired_post_slug`; leaving the trash restores it — but only when * that slug is not empty. A draft has no slug of its own (an empty * `post_name`), so a draft trashed and brought back stayed `__trashed`, with * the empty record left behind (Phase A undo gate, 2026-10-18). Wherever the * record is still there after the write, it is the before-state: the slug * goes back to it, and the record is removed as WordPress removes it. */ private static function put_back_desired_slug( int $post_id ): void { global $wpdb; if ( ! metadata_exists( 'post', $post_id, '_wp_desired_post_slug' ) ) { return; } $slug = (string) get_post_meta( $post_id, '_wp_desired_post_slug', true ); delete_post_meta( $post_id, '_wp_desired_post_slug' ); $post = get_post( $post_id ); if ( $post instanceof \WP_Post && (string) $post->post_name !== $slug ) { // One column of one row: the slug WordPress recorded; clean_post_cache() follows. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $wpdb->posts, array( 'post_name' => $slug ), array( 'ID' => $post_id ), array( '%s' ), array( '%d' ) ); clean_post_cache( $post_id ); } } /** * The status write itself, dates aside ({@see write_post_status()}). * * @return array{status_written: bool, comments_restored: bool|null} */ private static function write_post_status_only( int $post_id, string $previous, string $value ): array { if ( 'trash' === $value ) { $result = wp_trash_post( $post_id ); return array( 'status_written' => false !== $result && null !== $result, 'comments_restored' => null, ); } if ( 'trash' !== $previous ) { $result = wp_update_post( array( 'ID' => $post_id, 'post_status' => $value, ), true ); return array( 'status_written' => ! is_wp_error( $result ) && 0 !== $result, 'comments_restored' => null, ); } $post = get_post( $post_id ); $previous_status = (string) get_post_meta( $post_id, '_wp_trash_meta_status', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own hook, fired as wp_untrash_post() fires it. $check = apply_filters( 'pre_untrash_post', null, $post, $previous_status ); if ( null !== $check ) { return array( 'status_written' => false, 'comments_restored' => false, ); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own hook. do_action( 'untrash_post', $post_id, $previous_status ); $result = wp_update_post( array( 'ID' => $post_id, 'post_status' => $value, ), true ); if ( is_wp_error( $result ) || 0 === $result ) { return array( 'status_written' => false, 'comments_restored' => false, ); } delete_post_meta( $post_id, '_wp_trash_meta_status' ); delete_post_meta( $post_id, '_wp_trash_meta_time' ); self::put_back_desired_slug( $post_id ); $comments = wp_untrash_post_comments( $post_id ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core's own hook. do_action( 'untrashed_post', $post_id, $previous_status ); return array( 'status_written' => true, 'comments_restored' => false !== $comments && null !== $comments, ); } /** * POST /posts/{id}/status — move the post to a new reversible status * (publish/draft/private/trash). Returns the before/after status. Hard delete * is intentionally NOT supported here — trash is the reversible "delete". */ public function handle_write_status( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $not_content = Post_Storage::write_refusal( $post ); if ( null !== $not_content ) { return $not_content; } $value = sanitize_key( (string) $request->get_param( 'value' ) ); $allowed = array( 'publish', 'draft', 'private', 'trash', 'pending', 'future' ); if ( ! in_array( $value, $allowed, true ) ) { return new \WP_Error( 'wppilot_bad_request', 'Unsupported status.', array( 'status' => 400 ) ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to change this post status.', array( 'status' => 403 ) ); } if ( 'publish' === $value && ! current_user_can( 'publish_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to publish this post.', array( 'status' => 403 ) ); } if ( 'trash' === $value && ! current_user_can( 'delete_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to trash this post.', array( 'status' => 403 ) ); } if ( 'trash' === $value && self::trash_deletes( $post ) ) { // wp_trash_post() would delete it for good here: that cannot be undone. return new \WP_Error( 'wppilot_trash_deletes', 'This site\'s trash is switched off, so moving this to the trash would delete it permanently. Nothing was changed.', array( 'status' => 409 ) ); } $previous = (string) $post->post_status; $written = self::write_post_status( $post_id, $previous, $value ); $ok = $written['status_written']; if ( isset( $written['slug_taken'] ) ) { // Nothing was written: another post now has this one's address. $this->logger->info( 'Status write for post ' . $post_id . ' refused: its slug is taken by another post.' ); return new \WP_Error( 'wppilot_slug_taken', 'Another post now uses this one\'s address (' . $written['slug_taken'] . '), so bringing it back would give two posts the same address. Nothing was changed.', array( 'status' => 409, 'slug' => $written['slug_taken'] ) ); } if ( false === $written['comments_restored'] && $ok ) { $this->logger->warning( 'Post ' . $post_id . ' left the trash, but its comments could not be restored.' ); } if ( ! $ok ) { $this->logger->error( 'Failed to write status for post ' . $post_id ); return new \WP_Error( 'wppilot_write_failed', 'Could not update the post status.', array( 'status' => 500 ) ); } $this->logger->info( 'Status written for post ' . $post_id ); $cache = $this->cache_purger->purge_post_cache( $post_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $value, 'previous_value' => $previous, 'cache' => $cache, // Leaving the trash only: whether the post's comments came back with it. 'comments_restored' => $written['comments_restored'], ), 200 ); } /** * POST /posts — create a new page or post. Validates title/content/excerpt * and enforces the narrow WordPress capability for the requested post type * (edit_pages for pages, edit_posts for posts). The requested status is * limited to draft, publish, or private. Rollback * (created_object_trash) is handled by wp_trash_post($post_id). */ public function handle_create_draft( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_type = (string) $request->get_param( 'post_type' ); $post_type_object = get_post_type_object( $post_type ); if ( ! $post_type_object instanceof \WP_Post_Type || empty( $post_type_object->public ) || empty( $post_type_object->show_ui ) || '' === trim( (string) $post_type_object->label ) ) { return new \WP_Error( 'rest_invalid_post_type', 'That content type is not available for draft creation.', array( 'status' => 400 ) ); } $cap = ! empty( $post_type_object->cap->create_posts ) ? $post_type_object->cap->create_posts : $post_type_object->cap->edit_posts; if ( ! current_user_can( $cap ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to create a draft ' . $post_type . '.', array( 'status' => 403 ) ); } $title = sanitize_text_field( (string) $request->get_param( 'title' ) ); $excerpt = sanitize_text_field( (string) $request->get_param( 'excerpt' ) ); $raw_content = (string) $request->get_param( 'content' ); $status = sanitize_key( (string) $request->get_param( 'status' ) ); if ( ! in_array( $status, array( 'draft', 'publish', 'private' ), true ) ) { $status = 'draft'; } // Creating straight into publish or private needs the type's own // publish capability, as it does in WordPress's editor. if ( 'draft' !== $status && ! current_user_can( $post_type_object->cap->publish_posts ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to publish a ' . $post_type . '.', array( 'status' => 403 ) ); } $elementor_data = $request->get_param( 'elementor_data' ); $elementor_data = null === $elementor_data ? null : (string) $elementor_data; if ( null !== $elementor_data ) { $elementor_check = self::check_elementor_page_data( $elementor_data, defined( 'ELEMENTOR_VERSION' ) ); if ( is_wp_error( $elementor_check ) ) { return $elementor_check; } } $safe = $this->assert_content_safe( $raw_content ); if ( is_wp_error( $safe ) ) { return $safe; } $sanitized_content = wp_kses( $raw_content, $this->allowed_block_html() ); $post_data = array( 'post_type' => $post_type, 'post_title' => $title, 'post_content' => $sanitized_content, 'post_status' => $status, 'post_excerpt' => $excerpt, ); // wp_insert_post() expects slashed data and unslashes it: slash it so the // backslashes of an escaped block attribute (`\u0026`) survive. $post_id = wp_insert_post( wp_slash( $post_data ), true ); if ( is_wp_error( $post_id ) ) { $this->logger->error( 'Failed to create ' . $status . ' ' . $post_type . ': ' . $post_id->get_error_message() ); return new \WP_Error( 'wppilot_create_failed', 'Could not create the draft.', array( 'status' => 500 ) ); } if ( null !== $elementor_data ) { self::apply_elementor_page( (int) $post_id, $post_type, $elementor_data, (string) ELEMENTOR_VERSION ); } $preview_link = get_preview_post_link( $post_id ); $edit_link = get_edit_post_link( $post_id, 'raw' ); $this->logger->info( ucfirst( $status ) . ' ' . $post_type . ' created: ' . $post_id . ( null !== $elementor_data ? ' (Elementor)' : '' ) ); $cache = $this->cache_purger->purge_post_cache( (int) $post_id, null !== $elementor_data ); return new \WP_REST_Response( array( 'success' => true, 'post_id' => $post_id, 'post_type' => $post_type, 'post_status' => $status, 'builder' => null !== $elementor_data ? 'elementor' : 'gutenberg', 'preview_link' => (string) ( $preview_link ?: '' ), 'edit_link' => (string) ( $edit_link ?: '' ), 'cache' => $cache, ), 201 ); } /** Largest Elementor tree accepted when creating a page, in bytes. */ public const MAX_ELEMENTOR_CREATE_BYTES = 524288; /** * Whether `elementor_data` sent with POST /posts may be used to create an * Elementor page: Elementor must be active, and the value must be a JSON * list of Elementor elements within the size limit. Checked before the post * exists, so a refusal leaves nothing behind. * * @param string $json The element tree as sent. * @param bool $elementor_active Whether Elementor is loaded. * @return true|\WP_Error */ public static function check_elementor_page_data( string $json, bool $elementor_active ): bool|\WP_Error { if ( ! $elementor_active ) { return new \WP_Error( 'wppilot_elementor_inactive', 'Elementor is not active on this site, so the page cannot be created as an Elementor page.', array( 'status' => 409 ) ); } if ( strlen( $json ) > self::MAX_ELEMENTOR_CREATE_BYTES ) { return new \WP_Error( 'wppilot_invalid_elementor_data', 'Elementor data is larger than 512 KB.', array( 'status' => 400 ) ); } $decoded = json_decode( $json, true ); $is_list = is_array( $decoded ) && array_keys( $decoded ) === array_keys( array_values( $decoded ) ); if ( $is_list ) { foreach ( $decoded as $element ) { if ( ! is_array( $element ) || ! isset( $element['elType'] ) || ! is_string( $element['elType'] ) ) { $is_list = false; break; } } } if ( ! $is_list ) { return new \WP_Error( 'wppilot_invalid_elementor_data', 'Elementor data must be a JSON array of Elementor elements.', array( 'status' => 400 ) ); } // A new page has no earlier layout to compare against, so the rule a // new post body follows applies to every text in the tree: no script, // PHP, event handler or scripting address, whoever is connected. $refusal = Markup_Safety::content_refusal( Markup_Safety::strings_of( $decoded ) ); if ( null !== $refusal ) { return new \WP_Error( $refusal['code'], $refusal['message'] . ' Nothing was created.', array( 'status' => 422 ) ); } return true; } /** * Make a just-created post an Elementor page. Mirrors what Elementor * itself stores when a page is first saved in its editor; the data is * wp_slash()'d for the same reason as in handle_write_elementor_data(). * Pages get Elementor's "Full width" template, which keeps the theme's * header and footer around full-width content. */ public static function apply_elementor_page( int $post_id, string $post_type, string $json, string $elementor_version ): void { update_post_meta( $post_id, '_elementor_edit_mode', 'builder' ); update_post_meta( $post_id, '_elementor_template_type', 'page' === $post_type ? 'wp-page' : 'wp-post' ); update_post_meta( $post_id, '_elementor_version', $elementor_version ); update_post_meta( $post_id, '_elementor_data', wp_slash( $json ) ); if ( 'page' === $post_type ) { update_post_meta( $post_id, '_wp_page_template', 'elementor_header_footer' ); } } /** * What this build can do beyond its routes, for the backend to gate on. * * @return array{forms: bool, elementor_pages: bool} */ public static function features(): array { return array( 'forms' => true, 'elementor_pages' => defined( 'ELEMENTOR_VERSION' ), ); } /** * GET /posts/{id}/content — return the raw post body plus the editor-type * signals edit_post_content needs: whether the body carries Gutenberg block * delimiters, the pagebuilder detected on THIS post (via its postmeta), and * any known pagebuilder plugins active site-wide. */ public function handle_get_post_content( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $content = (string) $post->post_content; $signals = $this->pagebuilder_signals( $post_id, $content ); return new \WP_REST_Response( array( 'post_id' => $post_id, 'value' => $content, 'post_title' => sanitize_text_field( get_the_title( $post_id ) ), // The object's real registered type. Content actions that write // back need to know which surface they are writing to rather // than assuming every body is a post. 'post_type' => (string) get_post_type( $post_id ), 'permalink' => (string) ( get_permalink( $post_id ) ?: '' ), 'has_block_delimiters' => has_blocks( $content ), 'active_pagebuilder' => $signals['active_pagebuilder'], 'pagebuilder_plugins_active' => $signals['plugins'], ), 200 ); } /** * POST /posts/{id}/content — write the new post body after structure-aware * validation. Block markup is parsed and restricted to supported block types; * unsafe content is rejected without mutation so existing markup is preserved * byte-for-byte. Every write is snapshot-backed for rollback. */ public function handle_write_post_content( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $not_content = Post_Storage::write_refusal( $post ); if ( null !== $not_content ) { return $not_content; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this post.', array( 'status' => 403 ) ); } $value = (string) $request->get_param( 'value' ); $previous = (string) $post->post_content; /* * Compare-and-swap. The write happens only if the page still holds what * the caller read. * * This is what makes "exactly one write" a property of the SITE rather * than a promise from whoever called. Two concurrent restores of the * same change carry the same expected hash; the first changes the * content, so the second no longer matches and is refused instead of * landing on top of it. */ $expected = $request->get_param( 'expected_state_hash' ); if ( is_string( $expected ) && '' !== $expected ) { $actual = hash( 'sha256', $previous ); if ( ! hash_equals( $expected, $actual ) ) { return new \WP_Error( 'wppilot_content_state_drift', 'The page content changed since it was read; nothing was written.', array( 'status' => 409 ) ); } } $safe = $this->assert_content_safe( $value ); if ( is_wp_error( $safe ) ) { return $safe; } $result = wp_update_post( array( 'ID' => $post_id, // Edits are anchored and must never corrupt existing bytes; validate // and reject unsafe content instead of mutating it. Slashed, because // wp_update_post() unslashes: unslashed, every escaped block // attribute lost its backslashes (`\u0026` stored as `u0026`). 'post_content' => wp_slash( $value ), ), true ); if ( is_wp_error( $result ) ) { $this->logger->error( 'Failed to write content for post ' . $post_id ); return new \WP_Error( 'wppilot_write_failed', 'Could not update the post content.', array( 'status' => 500 ) ); } $this->logger->info( 'Content written for post ' . $post_id ); $cache = $this->cache_purger->purge_post_cache( $post_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $value, 'previous_value' => $previous, 'cache' => $cache, ), 200 ); } /** Per-value ceiling for a raw meta read, in encoded bytes. */ public const POST_RAW_VALUE_CAP = 65536; /** Ceiling for all meta of one post together, in encoded bytes. */ public const POST_RAW_TOTAL_CAP = 1048576; /** * GET /posts/{id}/raw — the post row and every postmeta row. * * This is the read that removes the need for a builder adapter: Elementor * keeps a page in `_elementor_data`, Beaver in `_fl_builder_data`, Bricks * in `_bricks_page_content_2`, ACF in its own keys, an SEO plugin in its own * — and all of it is here, as stored, for the service to reason over. * * Read-only and object-scoped: it returns one post the connected user may * edit, and nothing about any other object. Every value passes through * {@see Scan_Policy::redact_value()} before the response is built, so a * credential kept under a telling name, a nested credential-named field, * or a value whose shape is unmistakably a key never leaves the site. The * count of redactions is reported so a marker is read as "a secret was * here", never as a literal. Values are bounded per key and in total, and a * cut is declared rather than silent. */ public function handle_get_post_raw( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = get_post( $post_id ); if ( ! $post instanceof \WP_Post ) { return new \WP_Error( 'wppilot_post_not_found', 'Post not found.', array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'wppilot_forbidden', 'The connected user cannot edit this post.', array( 'status' => 403 ) ); } $meta = get_post_meta( $post_id ); $meta = is_array( $meta ) ? $meta : array(); $only = (string) $request->get_param( 'keys' ); if ( '' !== $only ) { $wanted = array_flip( array_filter( array_map( 'trim', explode( ',', $only ) ) ) ); $meta = array_intersect_key( $meta, $wanted ); } $shaped = self::shape_post_meta( $meta ); return new \WP_REST_Response( array( 'post_id' => $post_id, 'permalink' => (string) get_permalink( $post_id ), 'builder' => $this->builder_content->detect( $post_id ), 'post' => array( 'post_author' => (int) $post->post_author, 'post_date_gmt' => (string) $post->post_date_gmt, 'post_modified_gmt' => (string) $post->post_modified_gmt, 'post_status' => (string) $post->post_status, 'post_type' => (string) $post->post_type, 'post_name' => (string) $post->post_name, 'post_title' => (string) $post->post_title, 'post_excerpt' => (string) $post->post_excerpt, 'post_content' => (string) $post->post_content, 'post_parent' => (int) $post->post_parent, 'menu_order' => (int) $post->menu_order, 'comment_status' => (string) $post->comment_status, 'ping_status' => (string) $post->ping_status, 'post_mime_type' => (string) $post->post_mime_type, // Never the password itself; only whether one is set. 'password_protected' => '' !== (string) $post->post_password, ), 'meta' => $shaped['meta'], 'meta_keys' => $shaped['meta_keys'], 'redactions' => $shaped['redactions'], 'truncated' => $shaped['truncated'], 'fetched_at' => gmdate( 'c' ), ), 200 ); } /** * Whether a page cache and a persistent object cache are wired into this * site, from the facts WordPress itself keeps: the `WP_CACHE` constant and * the drop-in files in `wp-content`. A page cache is wired when both the * constant is on and `advanced-cache.php` exists — either alone does * nothing. Files only; nothing here is read from inside a drop-in. * * @param array $dropins `get_dropins()` output: file => header. * @return array{page_cache_wired: bool, wp_cache_constant: bool, advanced_cache_dropin: bool, object_cache_dropin: bool, dropins: array} */ public static function cache_wiring( bool $wp_cache, array $dropins ): array { $advanced = isset( $dropins['advanced-cache.php'] ); $files = array(); foreach ( array_keys( $dropins ) as $file ) { if ( is_string( $file ) && '' !== $file ) { $files[] = sanitize_text_field( $file ); } } sort( $files ); return array( 'page_cache_wired' => $wp_cache && $advanced, 'wp_cache_constant' => $wp_cache, 'advanced_cache_dropin' => $advanced, 'object_cache_dropin' => isset( $dropins['object-cache.php'] ), 'dropins' => $files, ); } /** * Must-use plugins: file, name and version, nothing else. Host-installed * caches and tooling live here and never in the installed-plugin list, so a * site-health read that omits them describes a site the owner does not have. * * @param array $raw `get_mu_plugins()` output: file => header. * @return array */ public static function shape_mu_plugins( array $raw ): array { $out = array(); foreach ( $raw as $file => $header ) { if ( ! is_string( $file ) || '' === $file || ! is_array( $header ) ) { continue; } $name = isset( $header['Name'] ) ? sanitize_text_field( (string) $header['Name'] ) : ''; $version = isset( $header['Version'] ) ? sanitize_text_field( (string) $header['Version'] ) : ''; $out[] = array( 'file' => sanitize_text_field( $file ), 'name' => '' !== $name ? $name : sanitize_text_field( $file ), 'version' => '' !== $version ? $version : null, ); } return $out; } /** * Shape a post's meta for transmission: redacted, bounded, declared. * * Pure — no WordPress calls — so it is exercised directly by * `plugin/tests/post-raw-test.php`. Input is `get_post_meta( $id )`'s shape: * key → list of (already unserialized) values. * * @param array> $meta * @return array{meta: array, redactions:int, truncated:bool}>, meta_keys:int, redactions:int, truncated:bool} */ public static function shape_post_meta( array $meta ): array { $out = array(); $redactions = 0; $truncated = false; $total = 0; ksort( $meta ); foreach ( $meta as $key => $values ) { if ( ! is_string( $key ) || ! is_array( $values ) ) { continue; } $entry = array( 'key' => $key, 'values' => array(), 'redactions' => 0, 'truncated' => false, ); foreach ( $values as $value ) { $redacted = Scan_Policy::redact_value( $key, $value ); $entry['redactions'] += $redacted['redactions']; $shaped = self::bound_meta_value( $redacted['value'], self::POST_RAW_VALUE_CAP ); if ( $shaped['truncated'] ) { $entry['truncated'] = true; } $total += $shaped['bytes']; if ( $total > self::POST_RAW_TOTAL_CAP ) { $entry['truncated'] = true; $truncated = true; break; } $entry['values'][] = $shaped['value']; } $redactions += $entry['redactions']; $truncated = $truncated || $entry['truncated']; $out[] = $entry; if ( $total > self::POST_RAW_TOTAL_CAP ) { break; } } return array( 'meta' => $out, 'meta_keys' => count( $meta ), 'redactions' => $redactions, 'truncated' => $truncated, ); } /** * Bound one meta value to a byte ceiling, declaring the cut. * * A string is cut with a marker that says how much followed. A structure * too large to send is replaced by a descriptor of its size, because a * truncated JSON tree is not a tree. * * @param mixed $value * @return array{value: mixed, bytes: int, truncated: bool} */ private static function bound_meta_value( mixed $value, int $cap ): array { if ( is_string( $value ) ) { $len = strlen( $value ); if ( $len <= $cap ) { return array( 'value' => $value, 'bytes' => $len, 'truncated' => false ); } $kept = substr( $value, 0, $cap ); return array( 'value' => $kept . sprintf( "\n[TRUNCATED: %d of %d bytes shown; the value continues]", $cap, $len ), 'bytes' => $cap, 'truncated' => true, ); } if ( is_scalar( $value ) || null === $value ) { return array( 'value' => $value, 'bytes' => strlen( (string) $value ), 'truncated' => false ); } $encoded = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $len = false === $encoded ? 0 : strlen( $encoded ); if ( $len <= $cap ) { return array( 'value' => $value, 'bytes' => $len, 'truncated' => false ); } return array( 'value' => array( '__wppilot_truncated' => true, 'bytes' => $len, 'note' => 'This structure is larger than the per-value ceiling and was not sent. Ask for a narrower read.', ), 'bytes' => 0, 'truncated' => true, ); } /** * GET /environment — the facts Site Health shows an administrator. * * Versions, limits, extensions, caches, cron posture, locale, the active * theme, free disk and the database's total size. Facts, not settings: * nothing here is a value the service could write back, and nothing here * is secret. Requires `manage_options`, the capability that reads Site * Health. */ public function handle_environment( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'wppilot_forbidden', 'The connected user cannot read the site environment.', array( 'status' => 403 ) ); } global $wpdb; $theme = wp_get_theme(); $parent = $theme->parent(); $db_bytes = null; if ( defined( 'DB_NAME' ) ) { // The one query Site Health itself makes for "Database size": a total, never rows. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $sum = $wpdb->get_var( $wpdb->prepare( 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = %s', DB_NAME ) ); $db_bytes = is_numeric( $sum ) ? (float) $sum : null; } $extensions = array(); foreach ( array( 'curl', 'gd', 'imagick', 'mbstring', 'intl', 'zip', 'xml', 'openssl', 'exif', 'sodium', 'bcmath', 'json' ) as $ext ) { $extensions[ $ext ] = extension_loaded( $ext ); } // A host may disable it (disable_functions); on PHP 8 calling it then is a fatal Error that @ does not stop. $disk_free = function_exists( 'disk_free_space' ) ? @disk_free_space( ABSPATH ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unavailable on some hosts; null is the honest answer. $server = isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( (string) $_SERVER['SERVER_SOFTWARE'] ) ) : null; $ready_cron = function_exists( 'wp_get_ready_cron_jobs' ) ? count( (array) wp_get_ready_cron_jobs() ) : null; /* * Whether a cache is WIRED, from the two facts WordPress keeps rather * than from the plugin list: a host's page cache (StackCache, Kinsta, * WP Engine, SiteGround) is a must-use plugin or a drop-in and never * appears among the plugins an owner installed. Declaring "no page * cache at all" from the plugin list alone was the first wrong answer * this read gave on a real site. */ if ( ! function_exists( 'get_dropins' ) || ! function_exists( 'get_mu_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $dropins = function_exists( 'get_dropins' ) ? (array) get_dropins() : array(); $mu_plugins = function_exists( 'get_mu_plugins' ) ? (array) get_mu_plugins() : array(); $wiring = self::cache_wiring( defined( 'WP_CACHE' ) && WP_CACHE, $dropins ); return new \WP_REST_Response( array( 'wp_version' => sanitize_text_field( (string) get_bloginfo( 'version' ) ), 'php_version' => PHP_VERSION, 'mysql_version' => sanitize_text_field( (string) $wpdb->db_version() ), 'server_software' => $server, 'environment_type' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : null, 'limits' => array( 'memory_limit' => (string) ini_get( 'memory_limit' ), 'wp_memory_limit' => defined( 'WP_MEMORY_LIMIT' ) ? (string) WP_MEMORY_LIMIT : null, 'wp_max_memory_limit' => defined( 'WP_MAX_MEMORY_LIMIT' ) ? (string) WP_MAX_MEMORY_LIMIT : null, 'max_execution_time' => (string) ini_get( 'max_execution_time' ), 'upload_max_filesize' => (string) ini_get( 'upload_max_filesize' ), 'post_max_size' => (string) ini_get( 'post_max_size' ), 'max_input_vars' => (string) ini_get( 'max_input_vars' ), ), 'extensions' => $extensions, 'caches' => array( 'external_object_cache' => (bool) wp_using_ext_object_cache(), 'opcache_enabled' => function_exists( 'opcache_get_status' ) && filter_var( ini_get( 'opcache.enable' ), FILTER_VALIDATE_BOOLEAN ), 'page_cache_wired' => $wiring['page_cache_wired'], 'wp_cache_constant' => $wiring['wp_cache_constant'], 'advanced_cache_dropin' => $wiring['advanced_cache_dropin'], 'object_cache_dropin' => $wiring['object_cache_dropin'], ), 'dropins' => $wiring['dropins'], 'mu_plugins' => self::shape_mu_plugins( $mu_plugins ), 'cron' => array( 'disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'alternate' => defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON, 'ready_jobs' => $ready_cron, ), 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'multisite' => is_multisite(), 'https' => is_ssl(), 'permalink_structure' => (string) get_option( 'permalink_structure' ), 'timezone' => (string) wp_timezone_string(), 'locale' => (string) get_locale(), 'theme' => array( 'name' => sanitize_text_field( (string) $theme->get( 'Name' ) ), 'version' => sanitize_text_field( (string) $theme->get( 'Version' ) ), 'stylesheet' => sanitize_key( (string) get_stylesheet() ), 'parent' => $parent ? sanitize_text_field( (string) $parent->get( 'Name' ) ) : null, 'is_block_theme' => function_exists( 'wp_is_block_theme' ) ? (bool) wp_is_block_theme() : false, ), 'disk_free_bytes' => is_float( $disk_free ) || is_int( $disk_free ) ? (float) $disk_free : null, 'database_bytes' => $db_bytes, 'features' => self::features(), 'fetched_at' => gmdate( 'c' ), ), 200 ); } /** * GET /posts/{id}/elementor — return the raw `_elementor_data` JSON string * for the post. Elementor stores the entire page layout as a JSON tree in * this postmeta and ignores post_content, so this is the source of truth the * backend parses to make a surgical, single-node text edit. `active` is false * when the post has no Elementor data (value is then an empty string). */ public function handle_get_elementor_data( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $raw_value = get_post_meta( $post_id, '_elementor_data', true ); if ( is_array( $raw_value ) ) { $value = wp_json_encode( $raw_value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); if ( false === $value ) { return new \WP_Error( 'wppilot_elementor_data_unreadable', 'Elementor data could not be encoded faithfully.', array( 'status' => 422 ) ); } } elseif ( is_string( $raw_value ) ) { $value = $raw_value; } elseif ( null === $raw_value || '' === $raw_value ) { $value = ''; } else { return new \WP_Error( 'wppilot_elementor_data_unreadable', 'Elementor data has an unsupported stored type.', array( 'status' => 422 ) ); } return new \WP_REST_Response( array_merge( array( 'post_id' => $post_id, 'post_title' => sanitize_text_field( get_the_title( $post_id ) ), 'active' => '' !== $value, 'value' => $value, ), // 1.5.8: what this document IS. A MetForm form or a theme-builder // template is stored exactly like a page; only these say it is // shared by every page that shows it. self::shape_elementor_document_meta( (string) $post->post_type, get_post_meta( $post_id, '_elementor_template_type', true ), get_post_meta( $post_id, '_elementor_conditions', true ) ), // 1.5.9: WHERE a shared document renders, asked for by the // preview and the documents read only (`?usage=1`), never by the // read-back after a write, which stays one meta read. $request->get_param( 'usage' ) && in_array( (string) $post->post_type, self::SHARED_ELEMENTOR_DOCUMENT_TYPES, true ) ? array( 'usage' => $this->elementor_document_usage( $post_id ) ) : array(), // 1.5.12: whether Elementor Pro is active (its form widget and // theme builder exist only then), and the document's other // language versions when Polylang or WPML translates it, so a // change to one says the others keep their content. array( 'elementor_pro' => defined( 'ELEMENTOR_PRO_VERSION' ) ), // 1.5.16: how the site sends mail, for the card of an edit that // adds or changes a form emailing its enquiries. array( 'mail' => Mail_Status::describe() ), $this->elementor_document_languages( $post_id, (string) $post->post_type ) ), 200 ); } /** * A document's language and its other language versions, from Polylang or * WPML, whichever translates it. Empty when neither is active. * * @param int $post_id The document. * @param string $post_type Its post type (WPML keys translations by it). * @return array{language?: string, translations?: array} */ private function elementor_document_languages( int $post_id, string $post_type ): array { $language = null; $map = array(); if ( function_exists( 'pll_get_post_language' ) && function_exists( 'pll_get_post_translations' ) ) { $language = pll_get_post_language( $post_id, 'slug' ); $map = (array) pll_get_post_translations( $post_id ); } elseif ( has_filter( 'wpml_element_trid' ) ) { $element_type = 'post_' . $post_type; $trid = apply_filters( 'wpml_element_trid', null, $post_id, $element_type ); if ( $trid ) { foreach ( (array) apply_filters( 'wpml_get_element_translations', null, $trid, $element_type ) as $code => $row ) { $row = (object) $row; if ( isset( $row->element_id ) ) { $map[ (string) ( isset( $row->language_code ) ? $row->language_code : $code ) ] = (int) $row->element_id; } } $details = apply_filters( 'wpml_post_language_details', null, $post_id ); $language = is_array( $details ) && isset( $details['language_code'] ) ? $details['language_code'] : null; } } return self::shape_document_languages( $post_id, is_string( $language ) ? $language : null, $map, static function ( int $id ): ?array { $post = get_post( $id ); return $post instanceof \WP_Post ? array( 'title' => sanitize_text_field( get_the_title( $id ) ), 'status' => (string) $post->post_status ) : null; } ); } /** * The language facts GET /posts/{id}/elementor reports, shaped and bounded. * A version that no longer exists, is in the trash, or is this document * itself is left out; a malformed code is dropped. * * @param int $self_id The document. * @param string|null $language Its language code. * @param array $map Language code => post id, as the translation plugin gives it. * @param callable $post_of Post id => array{title: string, status: string}|null. * @return array */ public static function shape_document_languages( int $self_id, ?string $language, array $map, callable $post_of ): array { $out = array(); $valid = static function ( $code ): bool { return is_string( $code ) && 1 === preg_match( '/^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})?$/', $code ); }; if ( $valid( $language ) ) { $out['language'] = $language; } $translations = array(); foreach ( $map as $code => $id ) { $id = (int) $id; if ( $id <= 0 || $id === $self_id || ! $valid( $code ) ) { continue; } $post = $post_of( $id ); if ( ! is_array( $post ) || 'trash' === $post['status'] ) { continue; } $translations[] = array( 'language' => (string) $code, 'post_id' => $id, 'title' => (string) $post['title'], 'status' => (string) $post['status'], ); if ( count( $translations ) >= 50 ) { break; } } if ( array() !== $translations ) { $out['translations'] = $translations; } return $out; } /** Statuses a document can render for: everything an owner would call a page of theirs. */ const USAGE_STATUSES = array( 'publish', 'draft', 'pending', 'private', 'future' ); /** Rendering posts listed per usage entry; the counts stay exact beyond it. */ const USAGE_SAMPLE = 10; /** Referencing posts listed at most. */ const USAGE_REFERENCES = 50; /** * Where a shared Elementor document (a template or a form) renders. * * Live, cloudtraveliq.com, 2026-10-10: asked to replace lorem ipsum in the * template that lays out every tour, WPPilot wrote one tour's name, length * and price into it, because nothing it read said the template IS every * tour page. This answers that with counts, from two things the site keeps: * * - `rules`: Elementor Pro's display conditions, resolved to the post types * (with counts by status and a few titles) or the posts they name. * - `embedded_in`: every post that references this document by its id: a * template or form widget in an Elementor tree (`"template_id":"3490"`, * `"mf_form_id":3317`, a global widget's `templateID`), or a shortcode or * block in post content (`[metform form_id="3317"]`, `{"ref":3490}`). * Post ids are unique across the posts table, so an id-named setting that * holds this id references this document. A referencing template carries * its own rules, one level deep, so a form inside the tour layout is said * to be on every tour page. * * @param int $post_id The document. * @return array */ private function elementor_document_usage( int $post_id ): array { $usage = array(); $conditions = self::string_conditions( get_post_meta( $post_id, '_elementor_conditions', true ) ); if ( array() !== $conditions ) { $usage['rules'] = $this->resolve_condition_scope( self::elementor_condition_scope( $conditions ) ); } $usage['embedded_in'] = $this->elementor_document_references( $post_id ); return $usage; } /** * Stored conditions as a clean list of strings (the cleaning the meta read applies). * * @param mixed $conditions `_elementor_conditions` as stored. * @return array */ private static function string_conditions( $conditions ): array { $shaped = self::shape_elementor_document_meta( '', null, $conditions ); return isset( $shaped['conditions'] ) ? $shaped['conditions'] : array(); } /** * Elementor Pro display conditions (`include|exclude/[/[/]]`) * read as the set of places they name. Pure: no WordPress calls. * * @param array $conditions Display conditions. * @return array{whole_site: bool, all_singular: bool, front_page: bool, not_found: bool, archives: bool, partial: bool, exceptions: bool, post_types: array, post_ids: array} */ public static function elementor_condition_scope( array $conditions ): array { $scope = array( 'whole_site' => false, 'all_singular' => false, 'front_page' => false, 'not_found' => false, 'archives' => false, 'partial' => false, 'exceptions' => false, 'post_types' => array(), 'post_ids' => array(), ); foreach ( $conditions as $condition ) { $parts = explode( '/', (string) $condition ); $mode = $parts[0] ?? ''; $name = $parts[1] ?? ''; $sub = $parts[2] ?? ''; $id = $parts[3] ?? ''; if ( 'exclude' === $mode ) { $scope['exceptions'] = true; continue; } if ( 'include' !== $mode ) { continue; } if ( 'general' === $name ) { $scope['whole_site'] = true; } elseif ( 'archive' === $name ) { $scope['archives'] = true; } elseif ( 'singular' !== $name ) { $scope['partial'] = true; } elseif ( '' === $sub ) { $scope['all_singular'] = true; } elseif ( 'front_page' === $sub ) { $scope['front_page'] = true; } elseif ( 'not_found404' === $sub ) { $scope['not_found'] = true; } elseif ( 1 === preg_match( '/^(?:in_|by_|child_of|any_child_of)/', $sub ) ) { // A taxonomy, author or parent rule: a subset not counted here. $scope['partial'] = true; } elseif ( '' !== $id && ctype_digit( $id ) ) { $scope['post_ids'][] = (int) $id; } elseif ( '' === $id && 1 === preg_match( '/^[a-z0-9_-]{1,40}$/', $sub ) ) { $scope['post_types'][] = $sub; } else { $scope['partial'] = true; } } $scope['post_types'] = array_values( array_unique( $scope['post_types'] ) ); $scope['post_ids'] = array_values( array_unique( $scope['post_ids'] ) ); return $scope; } /** * A condition scope with counts and titles from the site. * * @param array $scope From {@see elementor_condition_scope()}. * @return array */ private function resolve_condition_scope( array $scope ): array { $post_types = array(); foreach ( $scope['post_types'] as $type ) { $object = get_post_type_object( $type ); if ( ! $object instanceof \WP_Post_Type ) { continue; } $post_types[] = array( 'post_type' => $type, 'label' => sanitize_text_field( (string) $object->labels->name ), 'singular' => sanitize_text_field( (string) $object->labels->singular_name ), 'counts' => self::status_counts( wp_count_posts( $type ) ), 'sample' => self::post_rows( get_posts( // get_posts() already leaves query filters out by default. array( 'post_type' => $type, 'post_status' => self::USAGE_STATUSES, 'numberposts' => self::USAGE_SAMPLE, 'orderby' => 'date', 'order' => 'DESC', ) ) ), ); } $ids = $scope['post_ids']; if ( $scope['front_page'] ) { $front = (int) get_option( 'page_on_front' ); if ( $front > 0 ) { $ids[] = $front; } } $posts = array() === $ids ? array() : self::post_rows( get_posts( array( 'post__in' => array_slice( array_values( array_unique( $ids ) ), 0, self::USAGE_SAMPLE ), 'post_type' => 'any', 'post_status' => self::USAGE_STATUSES, 'numberposts' => self::USAGE_SAMPLE, ) ) ); return array( 'whole_site' => $scope['whole_site'], 'all_singular' => $scope['all_singular'], 'front_page' => $scope['front_page'], 'not_found' => $scope['not_found'], 'archives' => $scope['archives'], 'partial' => $scope['partial'], 'exceptions' => $scope['exceptions'], 'post_types' => $post_types, 'posts' => $posts, ); } /** * Posts that reference a document by its id (see {@see elementor_document_usage()}). * * @param int $post_id The document. * @return array{items: array, truncated: bool} */ private function elementor_document_references( int $post_id ): array { global $wpdb; $like = static function ( string $pattern ) use ( $wpdb ): string { return '%' . $wpdb->esc_like( $pattern ) . '%'; }; $meta_patterns = array_map( $like, self::reference_patterns( $post_id, false ) ); $body_patterns = array_map( $like, self::reference_patterns( $post_id, true ) ); $statuses = implode( ', ', array_fill( 0, count( self::USAGE_STATUSES ), '%s' ) ); $meta_where = implode( ' OR ', array_fill( 0, count( $meta_patterns ), 'm.meta_value LIKE %s' ) ); $body_where = implode( ' OR ', array_fill( 0, count( $body_patterns ), 'p.post_content LIKE %s' ) ); $limit = self::USAGE_REFERENCES + 1; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- a bounded read of ids; every value is a placeholder generated for exactly the values passed, and the interpolated parts are table names from $wpdb and fixed fragments. $from_meta = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_elementor_data' WHERE p.ID <> %d AND p.post_status IN ({$statuses}) AND ( {$meta_where} ) LIMIT %d", array_merge( array( $post_id ), self::USAGE_STATUSES, $meta_patterns, array( $limit ) ) ) ); $from_body = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM {$wpdb->posts} p WHERE p.ID <> %d AND p.post_status IN ({$statuses}) AND ( {$body_where} ) LIMIT %d", array_merge( array( $post_id ), self::USAGE_STATUSES, $body_patterns, array( $limit ) ) ) ); // phpcs:enable $ids = array_values( array_unique( array_map( 'intval', array_merge( (array) $from_meta, (array) $from_body ) ) ) ); $truncated = count( $ids ) > self::USAGE_REFERENCES; $items = array(); foreach ( array_slice( $ids, 0, self::USAGE_REFERENCES ) as $id ) { $post = get_post( $id ); if ( ! $post instanceof \WP_Post ) { continue; } $row = self::post_row( $post ); if ( 'elementor_library' === $post->post_type ) { $template_type = get_post_meta( $id, '_elementor_template_type', true ); if ( is_string( $template_type ) && '' !== $template_type && strlen( $template_type ) <= 64 ) { $row['template_type'] = $template_type; } $conditions = self::string_conditions( get_post_meta( $id, '_elementor_conditions', true ) ); if ( array() !== $conditions ) { $row['rules'] = $this->resolve_condition_scope( self::elementor_condition_scope( $conditions ) ); } } $items[] = $row; } return array( 'items' => $items, 'truncated' => $truncated, ); } /** * The literal fragments that reference a post id. Pure. * * In an Elementor tree an id-named setting holds it as a string or a number * (`"template_id":"3490"`, `"mf_form_id":3317,`, `"templateID":3490}`); in * post content a shortcode or block attribute does (`id="3490"`, * `form_id=3490]`, `{"ref":3490}`). * * @param int $post_id The referenced id. * @param bool $content Whether the fragments are for post content. * @return array */ public static function reference_patterns( int $post_id, bool $content ): array { $id = (string) $post_id; $patterns = array(); // Both spellings, for a column whose collation compares case: `templateID`. foreach ( array( 'id', 'ID' ) as $key ) { array_push( $patterns, $key . '":"' . $id . '"', $key . '":' . $id . ',', $key . '":' . $id . '}' ); } if ( $content ) { array_push( $patterns, 'id="' . $id . '"', "id='" . $id . "'", 'id=' . $id . ']', 'id=' . $id . ' ', '"ref":' . $id . ',', '"ref":' . $id . '}' ); } return $patterns; } /** * Counts by the statuses a document can render for. * * @param mixed $counts A wp_count_posts() result. * @return array */ public static function status_counts( $counts ): array { $out = array(); foreach ( self::USAGE_STATUSES as $status ) { $out[ $status ] = is_object( $counts ) && isset( $counts->$status ) ? (int) $counts->$status : 0; } return $out; } /** * Usage rows for a list of posts. * * @param array $posts WP_Post list. * @return array */ private static function post_rows( array $posts ): array { $rows = array(); foreach ( $posts as $post ) { if ( $post instanceof \WP_Post ) { $rows[] = self::post_row( $post ); } } return $rows; } /** * One post as usage reports it. * * @param \WP_Post $post The post. * @return array{id: int, title: string, post_type: string, status: string} */ private static function post_row( \WP_Post $post ): array { return array( 'id' => (int) $post->ID, 'title' => sanitize_text_field( get_the_title( $post ) ), 'post_type' => (string) $post->post_type, 'status' => (string) $post->post_status, ); } /** * Post types whose Elementor tree is shared by every page that shows it: * Elementor's templates (theme-builder headers, footers, single and archive * layouts, loop items, popups) and MetForm's forms. */ const SHARED_ELEMENTOR_DOCUMENT_TYPES = array( 'elementor_library', 'metform-form' ); /** * Post types whose `_elementor_data` WPPilot never writes, whatever their * registration says: code (Elementor Custom Code), submissions and leads, * and WordPress's own internal records. */ const REFUSED_ELEMENTOR_DOCUMENT_TYPES = array( 'elementor_snippet', 'elementor_font', 'elementor_icons', 'metform-entry', 'wppilot_lead', 'attachment', 'revision', 'nav_menu_item', 'customize_changeset', 'oembed_cache', 'user_request', 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', ); /** * Whether POST /posts/{id}/elementor may write this post type: a shared * Elementor document by name, otherwise only a public type with an admin UI * (pages, posts, a site's own public content types), and never a refused one. * * @param string $post_type The post's type. * @param bool $public_with_ui Whether that type is registered public with show_ui. */ public static function elementor_document_write_allowed( string $post_type, bool $public_with_ui ): bool { if ( in_array( $post_type, self::REFUSED_ELEMENTOR_DOCUMENT_TYPES, true ) ) { return false; } if ( in_array( $post_type, self::SHARED_ELEMENTOR_DOCUMENT_TYPES, true ) ) { return true; } return $public_with_ui; } /** * The document facts GET /posts/{id}/elementor reports beside the tree. * Malformed stored values are dropped rather than failing the read. * * @param string $post_type The post's type. * @param mixed $template_type `_elementor_template_type` as stored. * @param mixed $conditions `_elementor_conditions` as stored (Elementor Pro). * @return array{post_type: string, template_type?: string, conditions?: array} */ public static function shape_elementor_document_meta( string $post_type, $template_type, $conditions ): array { $out = array( 'post_type' => $post_type ); if ( is_string( $template_type ) && '' !== $template_type && strlen( $template_type ) <= 64 ) { $out['template_type'] = $template_type; } if ( is_array( $conditions ) ) { $list = array(); foreach ( $conditions as $condition ) { if ( is_string( $condition ) && '' !== $condition && strlen( $condition ) <= 200 ) { $list[] = $condition; } if ( count( $list ) >= 100 ) { break; } } if ( array() !== $list ) { $out['conditions'] = $list; } } return $out; } /** * POST /posts/{id}/elementor — write a new `_elementor_data` JSON string. The * backend has already parsed the tree, made a single field-scoped edit, and * re-serialised it; here we only persist and invalidate the cached CSS. * * The value is wp_slash()'d before update_post_meta because WP core unslashes * meta values on write — this mirrors Elementor's own save path and keeps the * stored JSON byte-for-byte what the backend computed (escaped quotes intact). * Every write is snapshot-backed on the backend for rollback. */ public function handle_write_elementor_data( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this post.', array( 'status' => 403 ) ); } $post_type = (string) $post->post_type; $post_type_object = get_post_type_object( $post_type ); $public_with_ui = $post_type_object instanceof \WP_Post_Type && ! empty( $post_type_object->public ) && ! empty( $post_type_object->show_ui ); if ( ! self::elementor_document_write_allowed( $post_type, $public_with_ui ) ) { return new \WP_Error( 'wppilot_elementor_document_refused', 'WPPilot does not change the Elementor layout of this kind of item.', array( 'status' => 403 ) ); } $value = (string) $request->get_param( 'value' ); $previous = $this->meta_value_to_string( get_post_meta( $post_id, '_elementor_data', true ) ); if ( null === $previous ) { return new \WP_Error( 'wppilot_elementor_data_unreadable', 'Existing Elementor data has an unsupported stored type.', array( 'status' => 422 ) ); } // Guard: the backend must send valid JSON. Never persist a corrupt tree. $decoded = json_decode( $value, true ); if ( null === $decoded && 'null' !== trim( $value ) ) { return new \WP_Error( 'wppilot_invalid_elementor_data', 'Elementor data was not valid JSON.', array( 'status' => 400 ) ); } /* * Compare-and-swap (1.5.24), as the body write has: the layout is written * only while it still hashes to what the caller read, so a write or an * undo never lands on top of an edit it did not see. Older backends send * no expectation and are written as before. */ $expected = $request->get_param( 'expected_state_hash' ); if ( is_string( $expected ) && '' !== $expected && ! hash_equals( $expected, hash( 'sha256', $previous ) ) ) { return new \WP_Error( 'wppilot_content_state_drift', 'The Elementor layout changed since it was read; nothing was written.', array( 'status' => 409 ) ); } /* * Elementor prints most text settings as stored, and an administrator * with `unfiltered_html` is not filtered by kses: a layout may not gain a * script, an event handler or a scripting address it did not already * hold (`Markup_Safety`, read as a browser reads it). */ $before_strings = Markup_Safety::elementor_strings( $previous ); $after_strings = Markup_Safety::elementor_strings( $value ); $unsafe = null === $after_strings ? null : Markup_Safety::change_refusal( (string) $before_strings, $after_strings ); if ( null !== $unsafe ) { return new \WP_Error( $unsafe['code'], $unsafe['message'], array( 'status' => 400 ) ); } $updated = update_post_meta( $post_id, '_elementor_data', wp_slash( $value ) ); if ( false === $updated ) { // update_post_meta returns false when the value is unchanged; treat a // genuine no-op as success but surface a real failure to write. if ( $previous !== $value ) { $this->logger->error( 'Failed to write _elementor_data for post ' . $post_id ); return new \WP_Error( 'wppilot_write_failed', 'Could not update the Elementor data.', array( 'status' => 500 ) ); } } // A form or template renders inside every page that shows it, so those // pages' caches go too, not only the document's own address. $cache = in_array( $post_type, self::SHARED_ELEMENTOR_DOCUMENT_TYPES, true ) ? $this->cache_purger->purge_shared_document_cache( $post_id ) : $this->cache_purger->purge_post_cache( $post_id, true ); $this->logger->info( 'Elementor data written for post ' . $post_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $value, 'previous_value' => $previous, 'cache' => $cache, ), 200 ); } /** * POST /elementor/regenerate — rebuild Elementor's generated files. * * What Elementor's own Tools → "Regenerate Files & Data" and "Replace URL" * do to its caches: the generated CSS and the locally stored Google Fonts * are deleted and rebuilt from the site's current address on the next * visit. Only caches are touched — no content, no settings — so there is * nothing to undo. `elementor_active` is false, and nothing is done, on a * site without Elementor. */ public function handle_regenerate_elementor( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { if ( ! current_user_can( 'manage_options' ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to regenerate Elementor files.', array( 'status' => 403 ) ); } $result = $this->cache_purger->regenerate_elementor_assets(); $this->logger->info( 'Elementor files regenerated: ' . ( implode( ', ', $result['cleared'] ) ?: 'none' ) ); return new \WP_REST_Response( $result, 200 ); } /** * GET /posts/{id}/builder-content — the page's editable text, normalized. * * Returns the leaves {@link Builder_Content} can prove are visible prose, * together with the complete authoritative state and its hash. The backend * decides which leaf the owner meant; it never receives, and never needs, the * serialized structure itself. */ public function handle_get_builder_content( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } $read = $this->builder_content->read( $post_id ); if ( $read instanceof \WP_Error ) { return $read; } return new \WP_REST_Response( array( 'post_id' => $post_id, 'post_title' => sanitize_text_field( get_the_title( $post_id ) ), 'permalink' => (string) get_permalink( $post_id ), 'builder' => $read['builder'], 'state_hash' => $read['state_hash'], 'state' => $read['state'], 'draft_state_hash' => $read['draft_state_hash'], 'draft_state' => $read['draft_state'], 'nodes' => $read['nodes'], ), 200 ); } /** * POST /posts/{id}/builder-content — apply one addressed text replacement. * * Every refusal this can return is a refusal to write: the page drifted, the * text drifted, the address is not editable text, or applying the edit would * not have produced the state the owner approved. A rollback is the same call * with the two texts swapped and the two hashes exchanged, so the reverse * write is guarded exactly as strictly as the forward one. */ public function handle_write_builder_content( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $post_id = (int) $request->get_param( 'id' ); $post = $this->addressed_post( $post_id ); if ( $post instanceof \WP_Error ) { return $post; } if ( ! current_user_can( 'edit_post', $post_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this post.', array( 'status' => 403 ) ); } $applied = $this->builder_content->apply( $post_id, array( 'node_id' => (string) $request->get_param( 'node_id' ), 'expected_text' => (string) $request->get_param( 'expected_text' ), 'new_text' => (string) $request->get_param( 'new_text' ), 'expected_state_hash' => (string) $request->get_param( 'expected_state_hash' ), 'result_state_hash' => (string) $request->get_param( 'result_state_hash' ), 'expected_draft_state_hash' => (string) $request->get_param( 'expected_draft_state_hash' ), 'result_draft_state_hash' => (string) $request->get_param( 'result_draft_state_hash' ), 'dry_run' => (bool) $request->get_param( 'dry_run' ), ) ); if ( $applied instanceof \WP_Error ) { return $applied; } // A dry run wrote nothing, so there is nothing to invalidate. $cache = empty( $applied['dry_run'] ) ? $this->cache_purger->purge_post_cache( $post_id, false ) : null; return new \WP_REST_Response( array( 'success' => true, 'builder' => $applied['builder'], 'dry_run' => (bool) ( $applied['dry_run'] ?? false ), 'previous_state_hash' => $applied['previous_state_hash'], 'state_hash' => $applied['state_hash'], 'state' => $applied['state'], 'draft_state_hash' => (string) ( $applied['draft_state_hash'] ?? '' ), 'draft_state' => (string) ( $applied['draft_state'] ?? '' ), 'cache' => $cache, ), 200 ); } /** * GET /media/{id}/alt-text — return the attachment's current alt text and the * public source URL. The backend fetches the image bytes from that URL to feed * the vision model; only image attachments are eligible (`is_image` is false * otherwise, and the backend declines those). */ public function handle_get_alt_text( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $attachment_id = (int) $request->get_param( 'id' ); $post = get_post( $attachment_id ); if ( ! $post instanceof \WP_Post || 'attachment' !== $post->post_type ) { return new \WP_Error( 'wppilot_attachment_not_found', 'Attachment not found.', array( 'status' => 404 ) ); } $mime = (string) get_post_mime_type( $attachment_id ); $is_image = ( 0 === strpos( $mime, 'image/' ) ); $alt = $this->meta_value_to_string( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) ?? ''; return new \WP_REST_Response( array( 'post_id' => $attachment_id, 'post_title' => sanitize_text_field( get_the_title( $attachment_id ) ), 'url' => esc_url_raw( (string) wp_get_attachment_url( $attachment_id ) ), 'mime_type' => $mime, 'is_image' => $is_image, 'current_alt' => $alt, ), 200 ); } /** * POST /media/{id}/alt-text — write the new alt text to the standard * `_wp_attachment_image_alt` postmeta. Alt text is plain text, so it is passed * through sanitize_text_field(); wp_slash() preserves apostrophes/quotes across * WP core's unslash-on-write. Every write is snapshot-backed on the backend for * rollback. */ public function handle_write_alt_text( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $attachment_id = (int) $request->get_param( 'id' ); $post = get_post( $attachment_id ); if ( ! $post instanceof \WP_Post || 'attachment' !== $post->post_type ) { return new \WP_Error( 'wppilot_attachment_not_found', 'Attachment not found.', array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_post', $attachment_id ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit this attachment.', array( 'status' => 403 ) ); } $value = sanitize_text_field( (string) $request->get_param( 'value' ) ); $previous = $this->meta_value_to_string( get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) ?? ''; $updated = update_post_meta( $attachment_id, '_wp_attachment_image_alt', wp_slash( $value ) ); if ( false === $updated && $previous !== $value ) { $this->logger->error( 'Failed to write alt text for attachment ' . $attachment_id ); return new \WP_Error( 'wppilot_write_failed', 'Could not update the alt text.', array( 'status' => 500 ) ); } $this->logger->info( 'Alt text written for attachment ' . $attachment_id ); return new \WP_REST_Response( array( 'success' => true, 'written_value' => $value, 'previous_value' => $previous, ), 200 ); } /** * Copy an approved remote image into this site's media library. * * Content that was hotlinking a stock CDN keeps working only while that CDN * does; an owner who publishes a page expects to own what is on it. This * runs at apply time rather than at preview, so nothing is written to the * media library for a draft the owner never approved. * * Idempotent by source URL: a retried apply, or two sections that resolved * to the same photograph, reuse the attachment already created rather than * filling the library with copies. * * @param \WP_REST_Request $request Sideload request. * @return \WP_REST_Response|\WP_Error The attachment, or why not. */ public function handle_sideload_media( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { if ( ! current_user_can( 'upload_files' ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to add to the media library.', array( 'status' => 403 ) ); } $url = esc_url_raw( trim( (string) $request->get_param( 'url' ) ) ); $parts = wp_parse_url( $url ); $scheme = is_array( $parts ) && isset( $parts['scheme'] ) ? strtolower( (string) $parts['scheme'] ) : ''; // Two controls, and neither is a host allowlist. WPPilot resolves images // from Openverse, which aggregates from a long and changing tail of // museum, Flickr and Wikimedia hosts, so an enumeration here would reject // most legitimate images while giving only the appearance of safety. // What actually protects the site is that this route requires the // callback token — so only WPPilot can reach it at all — and that // `media_sideload_image` fetches through `wp_safe_remote_get`, whose // `wp_http_validate_url` already refuses loopback, private ranges and // non-standard ports. https is required on top, because an image fetched // over plain http is one an intermediary chose. if ( 'https' !== $scheme ) { return new \WP_Error( 'wppilot_unsafe_url', 'Images can only be copied from an https address.', array( 'status' => 400 ) ); } // Already here from an earlier apply, or from another section of the // same page: reuse it rather than making a second copy. $existing = $this->find_sideloaded_attachment( $url ); if ( $existing > 0 ) { return new \WP_REST_Response( array( 'success' => true, 'attachment_id' => $existing, 'source_url' => (string) wp_get_attachment_url( $existing ), 'reused' => true, ), 200 ); } require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $attachment_id = media_sideload_image( $url, 0, null, 'id' ); if ( is_wp_error( $attachment_id ) ) { $this->logger->error( 'Media sideload failed: ' . $attachment_id->get_error_message() ); return new \WP_Error( 'wppilot_sideload_failed', 'Could not copy that image into the media library.', array( 'status' => 502 ) ); } $attachment_id = (int) $attachment_id; $alt = sanitize_text_field( (string) $request->get_param( 'alt' ) ); if ( '' !== $alt ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', wp_slash( $alt ) ); } // Recorded so a repeated apply finds this copy instead of making another, // and so the provenance of the file is visible in the media library. update_post_meta( $attachment_id, '_wppilot_source_url', wp_slash( $url ) ); $this->logger->info( 'Media sideloaded as attachment ' . $attachment_id ); return new \WP_REST_Response( array( 'success' => true, 'attachment_id' => $attachment_id, 'source_url' => (string) wp_get_attachment_url( $attachment_id ), 'reused' => false, ), 200 ); } /** * An attachment this site already sideloaded from the same source URL. * * @param string $url Remote source URL. * @return int Attachment ID, or 0. */ private function find_sideloaded_attachment( string $url ): int { $found = get_posts( array( 'post_type' => 'attachment', 'post_status' => 'inherit', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_key' => '_wppilot_source_url', 'meta_value' => $url, 'no_found_rows' => true, ) ); return is_array( $found ) && isset( $found[0] ) ? (int) $found[0] : 0; } /** * The HTML a generated post body may contain. * * `wp_kses_post()` alone is not quite the right allowlist here. It is the * one WordPress applies to *user-authored* content, and whether it permits * `
` and `` depends on the WordPress version — they * arrived with the Details block, and a site older than that would have the * accordion silently flattened into loose text on save. Silently, because * KSES strips rather than refuses: the post would be created, the readback * would not match what we sent, and the owner would be told their site * "altered the draft" with nothing naming the tag responsible. * * So the two elements the Details block needs are stated explicitly. Every * other tag still comes from core, so this is core's allowlist plus exactly * what our own renderer requires — not a widening of it. * * @return array> KSES-shaped allowed HTML. */ private function allowed_block_html(): array { $allowed = wp_kses_allowed_html( 'post' ); if ( ! isset( $allowed['details'] ) ) { $allowed['details'] = array( 'class' => true, 'open' => true, ); } if ( ! isset( $allowed['summary'] ) ) { $allowed['summary'] = array( 'class' => true ); } return $allowed; } /** * Pure safety validation for post/page content. * * - Gutenberg blocks are parsed and restricted to a supported allowlist. * - Executable scripts, event handlers, PHP tags, and unsafe URLs are rejected. * - This method never mutates the supplied content. * * The list below is a *safety* boundary, not a capability one: it exists so a * compromised or mistaken backend cannot write arbitrary blocks into an * owner's site. It must therefore be a superset of everything the backend's * renderer can emit, and it drifted from that: `core/details` was added to * the renderer for FAQ sections and never added here, so every draft * containing an FAQ was refused at the moment of writing — after the owner * had reviewed and approved it. The backend asserts the containment in * `plugin-block-allowlist.test.ts`, against this file, so the two cannot * drift again silently. * * @param string $content Raw content from the WPPilot backend. * @return true|\WP_Error True when safe, or an error if validation fails. */ private function assert_content_safe( string $content ): bool|\WP_Error { $allowed_blocks = array( 'core/paragraph', 'core/heading', 'core/list', 'core/list-item', 'core/image', 'core/gallery', 'core/quote', 'core/pullquote', 'core/separator', 'core/spacer', 'core/columns', 'core/column', 'core/group', 'core/buttons', 'core/button', 'core/embed', 'core/table', 'core/code', 'core/preformatted', 'core/verse', 'core/media-text', 'core/cover', 'core/file', 'core/details', 'core/shortcode', ); if ( has_blocks( $content ) ) { $blocks = parse_blocks( $content ); $unsupported = $this->find_unsupported_block( $blocks, $allowed_blocks ); if ( null !== $unsupported ) { return new \WP_Error( 'wppilot_unsupported_block', 'Unsupported block type: ' . sanitize_text_field( $unsupported ), array( 'status' => 400 ) ); } } /* * Scripts, PHP tags, inline event handlers and scripting addresses — * read the way a browser reads them (`Markup_Safety`): `javascript:` * is `javascript:`, and `` has an event handler. */ $refusal = Markup_Safety::content_refusal( $content ); if ( null !== $refusal ) { return new \WP_Error( $refusal['code'], $refusal['message'], array( 'status' => 400 ) ); } return true; } /** * Find the first unsupported block, including nested innerBlocks. * * @param array> $blocks Parsed Gutenberg blocks. * @param string[] $allowed_blocks Supported block names. */ private function find_unsupported_block( array $blocks, array $allowed_blocks ): ?string { foreach ( $blocks as $block ) { $name = $block['blockName'] ?? ''; if ( $name && ! in_array( $name, $allowed_blocks, true ) ) { return (string) $name; } $inner_blocks = $block['innerBlocks'] ?? array(); if ( is_array( $inner_blocks ) ) { $unsupported = $this->find_unsupported_block( $inner_blocks, $allowed_blocks ); if ( null !== $unsupported ) { return $unsupported; } } } return null; } /** * Detect pagebuilder usage for the edit_post_content safety checks. Returns * the builder bound to THIS post (via its postmeta / shortcodes) and the list * of known builder plugins active site-wide. * * @return array{active_pagebuilder: string|null, plugins: array} */ private function pagebuilder_signals( int $post_id, string $content ): array { $active = null; if ( '' !== $this->meta_value_to_string( get_post_meta( $post_id, '_elementor_data', true ) ) ) { $active = 'Elementor'; } elseif ( 'on' === $this->meta_value_to_string( get_post_meta( $post_id, '_et_pb_use_builder', true ) ) || false !== strpos( $content, '[et_pb_' ) ) { $active = 'Divi'; } elseif ( '' !== $this->meta_value_to_string( get_post_meta( $post_id, '_fl_builder_enabled', true ) ) ) { $active = 'Beaver Builder'; } elseif ( 'true' === $this->meta_value_to_string( get_post_meta( $post_id, '_wpb_vc_js_status', true ) ) || false !== strpos( $content, '[vc_row' ) ) { $active = 'WPBakery Page Builder'; } elseif ( is_array( get_post_meta( $post_id, '_bricks_page_content_2', true ) ) && array() !== get_post_meta( $post_id, '_bricks_page_content_2', true ) ) { $active = 'Bricks'; } elseif ( '' !== $this->meta_value_to_string( get_post_meta( $post_id, 'brizy', true ) ) ) { $active = 'Brizy'; } elseif ( '' !== $this->meta_value_to_string( get_post_meta( $post_id, 'ct_builder_shortcodes', true ) ) ) { $active = 'Oxygen'; } $known = array( 'elementor' => 'Elementor', 'elementor-pro' => 'Elementor', 'divi-builder' => 'Divi', 'beaver-builder-lite-version' => 'Beaver Builder', 'js_composer' => 'WPBakery Page Builder', 'brizy' => 'Brizy', 'oxygen' => 'Oxygen', ); $plugins = array(); foreach ( $this->plugin_inventory() as $plugin ) { if ( empty( $plugin['active'] ) ) { continue; } $slug = (string) ( $plugin['slug'] ?? '' ); if ( isset( $known[ $slug ] ) && ! in_array( $known[ $slug ], $plugins, true ) ) { $plugins[] = $known[ $slug ]; } } return array( 'active_pagebuilder' => $active, 'plugins' => $plugins, ); } /** * GET /core/status — passive Core readiness signal. Lite does not install, * activate or verify Core; it only reports whether a first-party WPPilot Core * package has signalled its presence through trusted site-bound means. At * launch this is always `not_installed`. */ public function handle_core_status( \WP_REST_Request $request ): \WP_REST_Response { /** * Discovery only. Lite reports what is present; it never installs, * activates or verifies anything, and it grants no authority by saying * so. Any plugin can hook this filter, which is precisely why the * backend treats the result as an observation and derives trust from * the provisioning record it holds — a module reported here is * `installed_unverified` at best until our own database says we served * this site that build. * * Reporting local state truthfully is not advertising an external * download (docs/compliance/wp-org-compliance.md §3.3): there is no * CTA, no link and no install code anywhere in this plugin. * * @param array $modules Modules present on this site. */ $modules = apply_filters( 'wppilot_advanced_modules', array() ); $modules = is_array( $modules ) ? array_values( array_filter( $modules, 'is_array' ) ) : array(); $sanitized = array(); foreach ( array_slice( $modules, 0, 8 ) as $module ) { $id = isset( $module['id'] ) ? sanitize_key( (string) $module['id'] ) : ''; if ( '' === $id ) { continue; } $surfaces = isset( $module['surfaces'] ) && is_array( $module['surfaces'] ) ? array_slice( array_map( 'sanitize_text_field', array_map( 'strval', $module['surfaces'] ) ), 0, 16 ) : array(); $sanitized[] = array( 'id' => $id, 'version' => sanitize_text_field( (string) ( $module['version'] ?? '' ) ), 'active' => ! empty( $module['active'] ), 'build_id' => isset( $module['build_id'] ) ? sanitize_text_field( (string) $module['build_id'] ) : null, 'health' => sanitize_text_field( (string) ( $module['health'] ?? 'unknown' ) ), 'requires_lite_at_least' => isset( $module['requires_lite_at_least'] ) ? sanitize_text_field( (string) $module['requires_lite_at_least'] ) : null, 'surfaces' => $surfaces, ); } $advanced = null; foreach ( $sanitized as $module ) { if ( 'advanced' === $module['id'] ) { $advanced = $module; break; } } return new \WP_REST_Response( array( 'installed' => null !== $advanced, 'active' => null !== $advanced && $advanced['active'], 'version' => null !== $advanced && '' !== $advanced['version'] ? $advanced['version'] : null, // Retained for older backends. Lite cannot know a trust state // and never claims one; the current backend ignores this field. 'trust_state' => 'not_installed', 'modules' => $sanitized, ), 200 ); } /** * GET /site-model — everything the backend's site-model-builder needs in one * round trip: identity, content counts, a recent content sample, the plugin * inventory, the theme, timezone, site permissions, and the abilities registry * (with the abilities_api_available flag for the graceful-degradation fork). */ public function handle_site_model( \WP_REST_Request $request ): \WP_REST_Response { $post_count = (int) wp_count_posts( 'post' )->publish; $page_count = (int) wp_count_posts( 'page' )->publish; $post_types = array(); foreach ( $this->offered_public_post_types() as $post_type_object ) { $counts = wp_count_posts( $post_type_object->name ); $post_types[] = array( 'name' => $post_type_object->name, 'label' => sanitize_text_field( (string) $post_type_object->label ), 'count' => isset( $counts->publish ) ? (int) $counts->publish : 0, 'create_allowed' => current_user_can( ! empty( $post_type_object->cap->create_posts ) ? $post_type_object->cap->create_posts : $post_type_object->cap->edit_posts ), ); } // WordPress reading settings: which page is the front page / blog page. $show_on_front = get_option( 'show_on_front' ); $page_on_front = 'page' === $show_on_front ? (int) get_option( 'page_on_front' ) : 0; $page_for_posts = 'page' === $show_on_front ? (int) get_option( 'page_for_posts' ) : 0; /* * A SAMPLE OF EVERY KIND OF THING THIS SITE HAS, not the ten most * recent of whatever was edited last. * * This asked for `array( 'post', 'page' )`, ten rows overall, and * reported every row as either `post` or `page`. Three separate ways of * saying that a WordPress site has two kinds of content, when the loop * directly above enumerates what this one actually offers. * * All three had consequences. A site whose recent activity is posts * sent ten posts and no page, so the consultant asked "which page do * you mean?" about a site it could not see the pages of. And a custom * type — `case_study` here, `product` on a shop — arrived LABELLED * `post`, which is worse than absent: absent is a gap, mislabelled is a * wrong answer nothing downstream can detect. * * So the sample is per type, drawn from the same registry the counts * above come from, and each row says what it actually is. Registering a * post type is the only thing anyone has to do for it to appear here. */ $recent = array(); $recent_ids = array(); $offered = $this->offered_public_post_types(); // Bounded twice: enough of each type to recognise something by name, // and a ceiling so a site with thirty registered types cannot make this // response unbounded. $per_type = 6; $sample_cap = 36; foreach ( $offered as $post_type_object ) { if ( count( $recent ) >= $sample_cap ) { break; } $query = new \WP_Query( array( 'post_type' => $post_type_object->name, 'post_status' => 'publish', 'posts_per_page' => $per_type, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, ) ); foreach ( $query->posts as $item ) { if ( ! $item instanceof \WP_Post || count( $recent ) >= $sample_cap ) { continue; } $recent[] = array( 'id' => $item->ID, 'title' => sanitize_text_field( get_the_title( $item ) ), 'slug' => $item->post_name, // What it IS. Collapsing this to post/page told the backend // a case study was a post. 'type' => $item->post_type, ); $recent_ids[] = $item->ID; } } // Ensure the front page is always in recent_items even if not recently modified. if ( $page_on_front > 0 && ! in_array( $page_on_front, $recent_ids, true ) ) { $fp = get_post( $page_on_front ); if ( $fp instanceof \WP_Post && 'publish' === $fp->post_status ) { array_unshift( $recent, array( 'id' => $fp->ID, 'title' => sanitize_text_field( get_the_title( $fp ) ), 'slug' => $fp->post_name, 'type' => 'page', ) ); } } $theme = wp_get_theme(); $inventory = $this->abilities->inventory(); return new \WP_REST_Response( array( 'site_url' => get_site_url(), 'site_title' => sanitize_text_field( get_bloginfo( 'name' ) ), 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'theme_name' => sanitize_text_field( (string) $theme->get( 'Name' ) ), 'timezone' => wp_timezone_string(), 'post_count' => $post_count, 'page_count' => $page_count, 'recent_items' => $recent, 'page_on_front' => $page_on_front, 'page_for_posts' => $page_for_posts, 'post_types' => $post_types, 'plugins' => $this->plugin_inventory(), 'hidden_code' => $this->hidden_code_inventory(), 'site_permissions' => $this->site_permissions(), 'site_permissions_probed' => $this->probed_permissions(), 'surfaces' => $this->registered_surfaces(), 'trash' => self::trash_settings(), 'abilities_api_available' => $inventory['available'], 'abilities' => $inventory['abilities'], 'features' => self::features(), ), 200 ); } /** * Which WPPilot endpoints this build of the plugin actually serves. * * Until now a site's reach was inferred from one string — the plugin * version — and a version is a promise about a build, not a description of * one. When 1.1.1 shipped a builder-content writer, every site running * "1.1.1" was assumed to have it, including the ones that did not, and the * backend learned the truth only by calling the route and reading a 404 as * "something is blocking us". * * So this is not a hand-written list, which would drift from the routes the * same way the version did. It is read back from the REST server after * registration: what is declared here is exactly what is served, because it * IS what is served. A route removed in a later build disappears from this * list without anyone remembering to edit it. * * Reported as `METHOD /path`, with the route's regex groups normalized to * `{name}` so the shape stays readable and stable across WordPress versions. * * @return array */ private function registered_surfaces(): array { if ( ! function_exists( 'rest_get_server' ) ) { return array(); } return self::surfaces_from_routes( rest_get_server()->get_routes() ); } /** * The declaration itself, as a pure function of the REST server's routes. * * Separated so it can be exercised directly: what this returns is what a * site tells WPPilot about itself, and a mistake here is a mistake about * every site at once. * * @param array>> $routes Routes, as the REST server reports them. * @return array */ public static function surfaces_from_routes( array $routes ): array { // The namespace every route above is registered under. $prefix = '/wppilot/v1'; $surfaces = array(); foreach ( $routes as $route => $handlers ) { if ( 0 !== strpos( (string) $route, $prefix ) ) { continue; } $path = substr( (string) $route, strlen( $prefix ) ); if ( '' === $path ) { continue; } $path = (string) preg_replace( '/\(\?P<([A-Za-z_]+)>[^)]*\)/', '{$1}', $path ); $methods = array(); foreach ( (array) $handlers as $handler ) { foreach ( array_keys( (array) ( $handler['methods'] ?? array() ) ) as $method ) { $methods[ (string) $method ] = true; } } foreach ( array_keys( $methods ) as $method ) { $surfaces[] = $method . ' ' . $path; } } $surfaces = array_values( array_unique( $surfaces ) ); sort( $surfaces ); return array_slice( $surfaces, 0, 96 ); } /** * WordPress capabilities the connected user is known to have. This is used by * the backend to surface `permission_required` blockers before execution. * The action handlers still enforce `current_user_can()` for the exact object. * * @return array */ private function site_permissions(): array { $out = array(); foreach ( $this->probed_permissions() as $cap ) { if ( current_user_can( $cap ) ) { $out[] = $cap; } } return $out; } /** * The capabilities `site_permissions()` asks about, reported alongside the * answer so the backend can tell "asked and not held" from "never asked". * * The list used to stop at the first twelve. `edit_theme_options` — what * WordPress requires to edit a menu — was never among them, so every * administrator was recorded as lacking it and menu edits were withheld on * every site. Every capability a WPPilot change can require is asked about. * * @return array */ private function probed_permissions(): array { return array( 'edit_posts', 'edit_pages', 'publish_posts', 'publish_pages', 'delete_posts', 'delete_pages', 'upload_files', 'manage_options', 'update_plugins', 'update_themes', 'update_core', 'install_plugins', 'edit_theme_options', 'activate_plugins', 'unfiltered_html', 'moderate_comments', ); } /** * What the site's own update channel says — tri-state, and never inferred. * * WordPress already knows. `wp theme list` prints `update: available, * update_version: 1.5` because core stores the answer in a site transient * that its own update cron refreshes. WPPilot never asked for it, so * `/inventory` carried an installed version and nothing else, and the * consultant asked "is my theme old?" had no fact to answer with. It * answered anyway: "not old at all, it's actually the newest of the default * themes", with 1.0 installed and 1.5 available. * * The shape below is the entire fix, and the important part is the THIRD * state. `update_available` is: * * true — the channel lists a newer release, and `latest_version` names it * false — the channel checked this item and found it current * null — WE DO NOT KNOW * * `null` is returned whenever the transient is absent, expired, or simply * does not mention this item — which happens on a fresh install, on a site * whose update cron has not run, and on any host that disables it. Collapsing * that case to `false` would reproduce the original bug one layer down: * silence read as reassurance. A reader that cannot tell "no update" from "no * answer" will eventually tell an owner they are current when nobody checked. * * Read-only by construction: this reads the transient core already populated * and never calls `wp_update_plugins()`/`wp_update_themes()`, which would * make a read endpoint reach out to WordPress.org on the owner's dime and * write the transient as a side effect. * * @param string $transient Site transient name, e.g. `update_plugins`. * @param string $key Item key: plugin file, or theme stylesheet. * @param string $installed The version installed now. * @return array{update_available: bool|null, latest_version: string|null} */ private function update_state( string $transient, string $key, string $installed ): array { return self::update_channel_state( get_site_transient( $transient ), $key, $installed ); } /** * The pure half of {@see update_state()}: one item's answer from a transient's contents. * * `false` needs a COMPLETED check of THIS item AT THE VERSION INSTALLED NOW. * Two shapes of the transient look like an answer and are not: * * - The placeholder. `wp_update_plugins()` writes `last_checked` into the * transient BEFORE it asks WordPress.org, so concurrent requests do not * all block on the same check; when that request fails or times out the * placeholder stays, with no `checked` map - and with whatever another * plugin's own update channel added to that transient before it was saved. * On wptech.group (bug #86) that is what was read after a rollback cleared * the cache: the S3 extension's vendor update and nothing else, so * WooCommerce and Widget Logic - both behind - looked current. * - A check made at another version. `checked` records the version that was * installed WHEN the check ran; after a rollback it names the version that * was taken off the site, and reporting it as `latest_version` with * `update_available: false` would call the removed release current. * * Either case is unknown, and the consumer reports unknown as unknown. * * @param mixed $data The transient's contents, as `get_site_transient()` returns them. * @param string $key Item key: plugin file, or theme stylesheet. * @param string $installed The version installed now. * @return array{update_available: bool|null, latest_version: string|null} */ public static function update_channel_state( $data, string $key, string $installed ): array { $unknown = array( 'update_available' => null, 'latest_version' => null, ); if ( ! is_object( $data ) ) { return $unknown; } $response = isset( $data->response ) && is_array( $data->response ) ? $data->response : array(); if ( isset( $response[ $key ] ) ) { $entry = $response[ $key ]; // Plugins hand back an object, themes an array. Both spell it // `new_version`, and an entry with no version is still an entry: // the channel is telling us an update exists. $new = is_object( $entry ) && isset( $entry->new_version ) ? (string) $entry->new_version : ( is_array( $entry ) && isset( $entry['new_version'] ) ? (string) $entry['new_version'] : '' ); return array( 'update_available' => true, 'latest_version' => '' !== $new ? sanitize_text_field( $new ) : null, ); } /* * Only a completed check of this item, at the version installed now, * BY A CHANNEL THAT KNOWS IT, licenses a `false`. * * `checked` alone is not that. Core writes it for EVERY installed item: * it is the list of versions core SENT, not the list WordPress.org * recognised. An item no channel knows (a plugin from outside the * directory, a custom plugin, a premium plugin whose updater is not * running) is in `checked` and in neither `response` nor `no_update`. * Reading `checked` alone reported such a plugin as "latest 2.5.0, no * update" when nobody had any idea what its latest release was * (alaskaholiday.co, 2026-10-12). `no_update` is the channel's own * statement that it knows this item and has nothing newer; without it * the honest answer is unknown. */ $checked = isset( $data->checked ) && is_array( $data->checked ) ? $data->checked : array(); if ( ! array_key_exists( $key, $checked ) ) { return $unknown; } $checked_at_version = (string) $checked[ $key ]; if ( $checked_at_version !== $installed ) { return $unknown; } $no_update = isset( $data->no_update ) && is_array( $data->no_update ) ? $data->no_update : array(); if ( ! isset( $no_update[ $key ] ) ) { return $unknown; } return array( 'update_available' => false, 'latest_version' => '' !== $checked_at_version ? sanitize_text_field( $checked_at_version ) : null, ); } /** * The directory slug for an installed plugin file. * * `dirname()` of a single-file plugin (`hello.php`) is "." - which is what * the inventory reported for Hello Dolly, and what every consumer then had * to special-case. WordPress's bundled `hello.php` is the directory's * `hello-dolly`; any other single file is named by its file name. * * @param string $file Plugin file relative to the plugins directory. * @return string */ public static function plugin_slug( string $file ): string { $dir = dirname( $file ); if ( '.' !== $dir && '' !== $dir ) { return $dir; } if ( 'hello.php' === $file ) { return 'hello-dolly'; } return (string) preg_replace( '/\.php$/', '', basename( $file ) ); } /** * The header facts that say who made a piece of code. * * Author, its URI, the plugin URI, and the `Update URI` header (WordPress * 5.8+; how a plugin declares where it is updated from). Facts for the * owner's "who makes this?", bounded, and null when the header is empty. * * @param array $header A `get_plugins()` / `get_mu_plugins()` / `get_dropins()` header. * @return array{author: string|null, author_uri: string|null, plugin_uri: string|null, update_uri: string|null} */ public static function origin_headers( array $header ): array { $text = static function ( string $name, int $max ) use ( $header ): ?string { if ( ! isset( $header[ $name ] ) || ! is_scalar( $header[ $name ] ) ) { return null; } $value = trim( sanitize_text_field( (string) $header[ $name ] ) ); if ( '' === $value ) { return null; } return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max ); }; return array( 'author' => $text( 'Author', 120 ), 'author_uri' => $text( 'AuthorURI', 200 ), 'plugin_uri' => $text( 'PluginURI', 200 ), 'update_uri' => $text( 'UpdateURI', 200 ), ); } /** * The plugin's own `Description` header, as plain bounded text. * * The consultant used to infer what a plugin does from its name, and * credited a firewall with filtering comment spam on a site whose anti-spam * plugin sat installed and switched off. The header is the plugin's own * statement of its job. Tags are stripped (headers may carry markup), and it * is cut to 160 characters: enough to say what it is for. * * @param array $header A `get_plugins()` header. * @return string|null Null when the header is empty. */ public static function plugin_description( array $header ): ?string { if ( ! isset( $header['Description'] ) || ! is_scalar( $header['Description'] ) ) { return null; } $value = trim( sanitize_text_field( wp_strip_all_tags( (string) $header['Description'] ) ) ); if ( '' === $value ) { return null; } return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, 160 ) : substr( $value, 0, 160 ); } /** * Must-use plugins and drop-ins, with who made them. * * Code the owner never sees in the Plugins screen's main list. Reported * beside the plugin list, never inside it: they cannot be activated, deactivated or * updated like plugins, and counting them as plugins would make every * "you have N plugins" disagree with wp-admin. * * @param array $raw `get_mu_plugins()` or `get_dropins()` output: file => header. * @return array> */ public static function shape_hidden_code( array $raw ): array { $out = array(); foreach ( $raw as $file => $header ) { if ( ! is_string( $file ) || '' === $file || ! is_array( $header ) ) { continue; } $name = isset( $header['Name'] ) ? sanitize_text_field( (string) $header['Name'] ) : ''; $version = isset( $header['Version'] ) ? sanitize_text_field( (string) $header['Version'] ) : ''; $out[] = array_merge( array( 'file' => sanitize_text_field( $file ), 'name' => '' !== $name ? $name : sanitize_text_field( $file ), 'version' => '' !== $version ? $version : null, ), self::origin_headers( $header ) ); if ( count( $out ) >= 50 ) { break; } } return $out; } /** * The same three states for WordPress core. * * Core reports through `update_core`, whose `updates` list carries a * `response` of `upgrade`, `latest` or `development` per offer. Anything * other than those two known answers is treated as unknown rather than * guessed at. * * @return array{update_available: bool|null, latest_version: string|null} */ private function core_update_state(): array { $unknown = array( 'update_available' => null, 'latest_version' => null, ); $data = get_site_transient( 'update_core' ); if ( ! is_object( $data ) || ! isset( $data->updates ) || ! is_array( $data->updates ) ) { return $unknown; } foreach ( $data->updates as $offer ) { $response = is_object( $offer ) && isset( $offer->response ) ? (string) $offer->response : ''; $current = is_object( $offer ) && isset( $offer->current ) ? (string) $offer->current : ''; if ( 'upgrade' === $response ) { return array( 'update_available' => true, 'latest_version' => '' !== $current ? sanitize_text_field( $current ) : null, ); } if ( 'latest' === $response ) { return array( 'update_available' => false, 'latest_version' => '' !== $current ? sanitize_text_field( $current ) : null, ); } } return $unknown; } private function plugin_inventory(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } /* * IN THE SITE'S OWN LANGUAGE, not the requesting identity's. * * get_plugins() translates each plugin's Name through whatever locale * determine_locale() resolves for the current request, which for a REST * call is the authenticated user's. WPPilot authenticates as itself, so * on moneyfoxx.com — a de_DE site — it reported "Payment Plugins for * PayPal WooCommerce" while the owner's wp-admin says * "Zahlungs-Plugins fur PayPal WooCommerce". The consultant renamed the * owner's plugin into another language. * * The site's locale is the site's own vocabulary, and it does not * change with who is asking. The cached entry is dropped first because * get_plugins() caches the ALREADY TRANSLATED array, so switching the * locale without clearing it returns the previous language. */ $switched = function_exists( 'switch_to_locale' ) && get_locale() !== determine_locale() && switch_to_locale( get_locale() ); if ( $switched ) { wp_cache_delete( 'plugins', 'plugins' ); } $all = get_plugins(); if ( $switched ) { restore_previous_locale(); // Leave no translated-in-another-locale array behind for the next // reader of this cache key. wp_cache_delete( 'plugins', 'plugins' ); } $transient = get_site_transient( 'update_plugins' ); $out = array(); foreach ( $all as $file => $data ) { $file = (string) $file; $data = is_array( $data ) ? $data : array(); $update = self::update_channel_state( $transient, $file, (string) ( $data['Version'] ?? '' ) ); $out[] = array_merge( array( 'name' => sanitize_text_field( (string) ( $data['Name'] ?? $file ) ), 'slug' => self::plugin_slug( $file ), 'file' => sanitize_text_field( $file ), 'version' => sanitize_text_field( (string) ( $data['Version'] ?? '' ) ), 'active' => is_plugin_active( $file ), // Tri-state. `null` means nobody checked, and must never be // read as "current" — see update_channel_state(). 'update_available' => $update['update_available'], 'latest_version' => $update['latest_version'], // What the plugin says it does, so its purpose is read // rather than guessed from its name. 'description' => self::plugin_description( $data ), ), self::origin_headers( $data ) ); } return $out; } /** * Must-use plugins and drop-ins for the inventory, read without side effects. * * @return array{mu_plugins: array>, dropins: array>} */ private function hidden_code_inventory(): array { if ( ! function_exists( 'get_dropins' ) || ! function_exists( 'get_mu_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } return array( 'mu_plugins' => function_exists( 'get_mu_plugins' ) ? self::shape_hidden_code( (array) get_mu_plugins() ) : array(), 'dropins' => function_exists( 'get_dropins' ) ? self::shape_hidden_code( (array) get_dropins() ) : array(), ); } /** * Every INSTALLED theme, not only the active one. * * Asked "which of my themes can I safely get rid of?", the consultant could * only see the active theme and had to answer with a rule of thumb. Whether * a theme is safe to delete is a fact about this list: is it active, is it * the active theme's parent, is it behind on updates. * * @return array> */ private function theme_inventory(): array { $active = wp_get_theme(); $out = array(); foreach ( wp_get_themes() as $stylesheet => $theme ) { $update = $this->update_state( 'update_themes', (string) $stylesheet, (string) $theme->get( 'Version' ) ); $out[] = array( 'name' => sanitize_text_field( (string) $theme->get( 'Name' ) ), 'slug' => sanitize_key( (string) $stylesheet ), 'version' => sanitize_text_field( (string) $theme->get( 'Version' ) ), 'active' => $active->get_stylesheet() === $stylesheet, // The active theme's parent must stay installed even though it is // not the active theme. 'parent_of_active' => $active->get_template() === $stylesheet && $active->get_stylesheet() !== $stylesheet, 'update_available' => $update['update_available'], 'latest_version' => $update['latest_version'], ); } return $out; } /* ---- Layer 3 read-only handlers ------------------------------------- */ /** * GET /post?id=|slug= — full current content of a single post/page/CPT. * Read-only (brief Part 7.5 Lite boundary). Returns title, content, excerpt, * meta description (via the detected SEO plugin), status, modified time, * post_type, author, featured image, categories, and tags — and, since * 1.5.19, the post's custom field values ({@see Custom_Fields}). */ public function handle_get_post( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $id_param = $request->get_param( 'id' ); $slug_param = $request->get_param( 'slug' ); $post = null; if ( null !== $id_param && is_numeric( $id_param ) ) { $post = get_post( (int) $id_param ); } elseif ( is_string( $slug_param ) && '' !== $slug_param ) { $slug = sanitize_title( $slug_param ); $found = get_posts( array( 'name' => $slug, 'post_type' => $this->public_post_types(), 'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ), 'posts_per_page' => 1, 'no_found_rows' => true, ) ); $post = ! empty( $found ) ? $found[0] : null; } else { return new \WP_Error( 'wppilot_bad_request', 'Provide an id or slug.', array( 'status' => 400 ) ); } if ( ! $post instanceof \WP_Post ) { return new \WP_Error( 'wppilot_post_not_found', 'Post not found.', array( 'status' => 404 ) ); } return new \WP_REST_Response( $this->post_detail( $post ), 200 ); } /** * GET /posts?type=&limit=&order_by=&status=&after= — list posts/pages. * * Read-only listing with title, slug, status, modified time, and author. * * PAGINATION. `limit` is clamped to 50, and always was, so a caller asking * for more silently received fifty — a site with seventy pages could never * be enumerated, and the backend had no way to tell a complete listing from * a truncated one. Passing `after` walks the whole set instead. * * The cursor is a POST ID and the order is `ID ASC`, which is the only * stable keyset available here: `date`, `modified` and `title` are none of * them unique, so a cursor on any of them can skip or repeat rows where two * posts tie. Keyset rather than offset for the same reason — a post * published or deleted mid-walk shifts every offset after it, and the item * the caller was looking for is the one that gets skipped. * * `total` is returned only in the paginated mode, because computing it * costs a second query; the caller uses it to notice a site that changed * underneath the walk and degrade to a partial answer rather than claim an * absence it can no longer prove. * * A caller that passes no `after` gets exactly the previous behaviour, and * an older plugin answering a paginated request simply omits `has_more` and * `next_after` — which the backend must read as "unknown", never as * "complete". That absence IS the capability negotiation. */ public function handle_list_posts( \WP_REST_Request $request ): \WP_REST_Response { $type = sanitize_key( (string) ( $request->get_param( 'type' ) ?? 'post' ) ); $limit = $this->clamp_int( $request->get_param( 'limit' ), 20, 1, 50 ); $raw_after = $request->get_param( 'after' ); $paginated = null !== $raw_after && '' !== (string) $raw_after; $after = $paginated ? max( 0, (int) $raw_after ) : 0; $order_by = sanitize_key( (string) ( $request->get_param( 'order_by' ) ?? 'date' ) ); $order = strtoupper( sanitize_key( (string) ( $request->get_param( 'order' ) ?? '' ) ) ); $status = sanitize_key( (string) ( $request->get_param( 'status' ) ?? 'publish' ) ); $allowed_types = $this->public_post_types(); if ( ! in_array( $type, $allowed_types, true ) ) { $type = 'post'; } $allowed_orderby = array( 'date', 'modified', 'title', 'id' ); if ( ! in_array( $order_by, $allowed_orderby, true ) ) { $order_by = 'date'; } // A cursor is meaningless against a non-unique sort key, so the // paginated mode fixes the order rather than half-honouring a request // it cannot page through correctly. if ( $paginated ) { $order_by = 'id'; } $allowed_status = array( 'publish', 'draft', 'pending', 'private', 'future', 'any' ); if ( ! in_array( $status, $allowed_status, true ) ) { $status = 'publish'; } if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) { $order = 'title' === $order_by ? 'ASC' : 'DESC'; } if ( $paginated ) { $order = 'ASC'; } // Ask for one more than the page, so "is there another" is answered by // the rows themselves rather than by comparing counts that a concurrent // write can make disagree. /* * `WP_Query` spells the post-ID sort key `ID`, and `sanitize_key()` * above has lowercased it. Passing `id` is not rejected — it is * silently ignored, and the query falls back to ordering by date, which * leaves the keyset cursor pointing at whichever row happened to come * last rather than at the largest ID returned. The walk then skips rows. */ $query_orderby = 'id' === $order_by ? 'ID' : $order_by; $args = array( 'post_type' => $type, 'post_status' => $status, 'posts_per_page' => $paginated ? $limit + 1 : $limit, 'orderby' => $query_orderby, 'order' => $order, 'no_found_rows' => ! $paginated, ); if ( $paginated ) { $args['wppilot_after_id'] = $after; } if ( $paginated ) { add_filter( 'posts_where', array( $this, 'filter_posts_after_id' ), 10, 2 ); } $query = new \WP_Query( $args ); if ( $paginated ) { remove_filter( 'posts_where', array( $this, 'filter_posts_after_id' ), 10 ); } $posts = $query->posts; $has_more = false; if ( $paginated && count( $posts ) > $limit ) { $has_more = true; $posts = array_slice( $posts, 0, $limit ); } $items = array(); $next_after = null; foreach ( $posts as $item ) { if ( ! $item instanceof \WP_Post ) { continue; } $items[] = $this->post_summary( $item ); $next_after = (int) $item->ID; } $payload = array( 'post_type' => $type, 'post_type_label' => $this->post_type_label( $type ), 'order_by' => $order_by, 'order' => strtolower( $order ), 'status' => $status, 'items' => $items, ); if ( $paginated ) { // Present only here, and their absence is how a caller detects a // plugin that cannot paginate at all. $payload['page_size'] = $limit; $payload['has_more'] = $has_more; $payload['next_after'] = $has_more ? $next_after : null; $payload['total'] = (int) $query->found_posts; } return new \WP_REST_Response( $payload, 200 ); } /** * Keyset predicate for the paginated listing: strictly after a post ID. * * Registered only for the duration of the paginated query above, and reads * its bound from the query's own args so two concurrent requests cannot * see each other's cursor. * * @param string $where Existing WHERE clause. * @param \WP_Query $query The query being filtered. */ public function filter_posts_after_id( $where, $query ) { global $wpdb; $after = $query->get( 'wppilot_after_id' ); if ( ! is_numeric( $after ) || (int) $after <= 0 ) { return $where; } return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", (int) $after ); } /** * GET /search?query=&post_types=&limit= — lexical search (WordPress core * search across title + content + excerpt) returning matches with a snippet. */ public function handle_search( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $query_text = trim( (string) ( $request->get_param( 'query' ) ?? '' ) ); if ( '' === $query_text ) { return new \WP_Error( 'wppilot_bad_request', 'Provide a query.', array( 'status' => 400 ) ); } $query_text = sanitize_text_field( $query_text ); $limit = $this->clamp_int( $request->get_param( 'limit' ), 10, 1, 25 ); $status = sanitize_key( (string) ( $request->get_param( 'status' ) ?? 'any' ) ); $allowed_status = array( 'publish', 'draft', 'pending', 'private', 'future', 'any' ); if ( ! in_array( $status, $allowed_status, true ) ) { $status = 'any'; } $types_param = (string) ( $request->get_param( 'post_types' ) ?? '' ); $types = array(); if ( '' !== $types_param ) { foreach ( explode( ',', $types_param ) as $t ) { $t = sanitize_key( trim( $t ) ); if ( '' !== $t && in_array( $t, $this->public_post_types(), true ) ) { $types[] = $t; } } } if ( empty( $types ) ) { $types = $this->public_post_types(); } // Include the site owner's unpublished work too: a page they just created // (still a draft) or scheduled must be findable when they name it. This is // the authenticated owner's own tool, so drafts/pending/private/scheduled // are all fair game — only trash/auto-draft are excluded. global $wpdb; $statuses = 'any' === $status ? array( 'publish', 'draft', 'pending', 'private', 'future' ) : array( $status ); $posts = array(); $seen = array(); // (1) Fuzzy slug match. WordPress core search (`s`) only looks at title, // content and excerpt — never the slug — so a user who names a page by its // URL slug ("the testtest page") gets zero hits even though the page plainly // exists. We match the slug against each meaningful word in the query with a // partial LIKE, so "testtest" finds the `testtest` slug and "test" surfaces // `testing` too — giving the resolver a plausible candidate to suggest // ("closest match — did you mean …?") rather than a flat "not found". $stopwords = array( 'the', 'a', 'an', 'my', 'our', 'your', 'this', 'that', 'page', 'pages', 'post', 'posts', 'on', 'in', 'to', 'of', 'for', 'and', 'change', 'edit', 'update', 'fix', ); $tokens = array(); foreach ( preg_split( '/[^a-z0-9]+/', strtolower( $query_text ) ) as $word ) { if ( strlen( $word ) >= 3 && ! in_array( $word, $stopwords, true ) ) { $tokens[ $word ] = true; } } $tokens = array_keys( $tokens ); if ( ! empty( $tokens ) ) { $clauses = array(); foreach ( $tokens as $word ) { $clauses[] = $wpdb->prepare( "{$wpdb->posts}.post_name LIKE %s", '%' . $wpdb->esc_like( $word ) . '%' ); } $slug_where = static function ( string $where ) use ( $clauses ): string { return $where . ' AND ( ' . implode( ' OR ', $clauses ) . ' )'; }; add_filter( 'posts_where', $slug_where ); $slug_query = new \WP_Query( array( 'post_type' => $types, 'post_status' => $statuses, 'posts_per_page' => $limit, 'orderby' => 'post_name', 'order' => 'ASC', 'no_found_rows' => true, ) ); remove_filter( 'posts_where', $slug_where ); foreach ( $slug_query->posts as $item ) { if ( $item instanceof \WP_Post && ! isset( $seen[ $item->ID ] ) ) { $seen[ $item->ID ] = true; $posts[] = $item; } } } // (2) Lexical search across title/content/excerpt for everything else. $query = new \WP_Query( array( 's' => $query_text, 'post_type' => $types, 'post_status' => $statuses, 'posts_per_page' => $limit, 'no_found_rows' => true, ) ); foreach ( $query->posts as $item ) { if ( $item instanceof \WP_Post && ! isset( $seen[ $item->ID ] ) ) { $seen[ $item->ID ] = true; $posts[] = $item; } } $items = array(); foreach ( array_slice( $posts, 0, $limit ) as $item ) { $summary = $this->post_summary( $item ); $summary['snippet'] = $this->snippet( $item, $query_text ); $items[] = $summary; } return new \WP_REST_Response( array( 'query' => $query_text, 'status' => $status, 'items' => $items, ), 200 ); } /** * GET /site-inventory — fresh environment + plugin inventory. The freshness * signal the backend's get_site_inventory_fresh tool needs: it re-reads the * live site rather than the cached site model. */ public function handle_site_inventory( \WP_REST_Request $request ): \WP_REST_Response { global $wpdb; $post_count = 0; foreach ( $this->public_post_types() as $pt ) { $counts = wp_count_posts( $pt ); $post_count += isset( $counts->publish ) ? (int) $counts->publish : 0; } $opcache_enabled = false; if ( function_exists( 'opcache_get_status' ) ) { $status = @opcache_get_status( false ); $opcache_enabled = is_array( $status ) && ! empty( $status['opcache_enabled'] ); } $disk_free = function_exists( 'disk_free_space' ) ? @disk_free_space( ABSPATH ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- may be disabled by the host. $theme = wp_get_theme(); /* * The update channel for the two things that are not plugins. Read from * the transient core maintains; never refreshed here, so a read stays a * read. */ $theme_update = $this->update_state( 'update_themes', $theme->get_stylesheet(), (string) $theme->get( 'Version' ) ); $core_update = $this->core_update_state(); return new \WP_REST_Response( array( 'post_count' => $post_count, 'plugins' => $this->plugin_inventory(), 'hidden_code' => $this->hidden_code_inventory(), 'themes' => $this->theme_inventory(), 'theme_name' => sanitize_text_field( (string) $theme->get( 'Name' ) ), 'theme_version' => sanitize_text_field( (string) $theme->get( 'Version' ) ), 'theme_update_available' => $theme_update['update_available'], 'theme_latest_version' => $theme_update['latest_version'], 'wp_version' => get_bloginfo( 'version' ), 'wp_update_available' => $core_update['update_available'], 'wp_latest_version' => $core_update['latest_version'], 'php_version' => PHP_VERSION, 'php_memory_limit' => (string) ini_get( 'memory_limit' ), 'max_execution_time' => (string) ini_get( 'max_execution_time' ), 'opcache_enabled' => $opcache_enabled, 'disk_free_bytes' => is_float( $disk_free ) || is_int( $disk_free ) ? (float) $disk_free : null, 'mysql_version' => sanitize_text_field( (string) $wpdb->db_version() ), 'fetched_at' => gmdate( 'c' ), ), 200 ); } /** * GET /theme-style — the active theme's own design tokens. * * Read-only, and deliberately the theme's settings rather than anything * WPPilot decides: colour and gradient slugs, font size and family slugs, * the layout widths, which alignments the theme supports, and which blocks * and block styles are actually registered on this WordPress version. That * last part is what lets generated content use a block only where the editor * would recognise it, instead of assuming every site runs current core. * * Slugs are what matter: a generated document references `accent-2`, never * `#0b5cff`, so it inherits the theme's design and keeps working when the * owner edits their palette or switches theme. */ public function handle_theme_style( \WP_REST_Request $request ): \WP_REST_Response { $theme = wp_get_theme(); $is_block_theme = function_exists( 'wp_is_block_theme' ) ? (bool) wp_is_block_theme() : false; $layout = $this->global_layout_settings(); // A block theme declares its wide width in theme.json rather than by // calling add_theme_support( 'align-wide' ), so the classic support // flag alone reports false on themes that plainly do support it. $align_wide = current_theme_supports( 'align-wide' ) || ( $is_block_theme && '' !== $layout['wide_size'] ); return new \WP_REST_Response( array( 'theme_name' => sanitize_text_field( (string) $theme->get( 'Name' ) ), 'theme_stylesheet' => sanitize_key( (string) get_stylesheet() ), 'is_block_theme' => $is_block_theme, 'wp_version' => sanitize_text_field( (string) get_bloginfo( 'version' ) ), 'palette' => $this->global_setting_slugs( array( 'color', 'palette' ), 'color' ), 'gradients' => $this->global_setting_slugs( array( 'color', 'gradients' ), 'gradient' ), 'font_sizes' => $this->global_setting_slugs( array( 'typography', 'fontSizes' ), 'size' ), 'font_families' => $this->global_setting_slugs( array( 'typography', 'fontFamilies' ), 'fontFamily' ), 'layout' => $layout, 'supports' => array( 'align_wide' => (bool) $align_wide, 'editor_styles' => (bool) current_theme_supports( 'editor-styles' ), 'appearance_tools' => (bool) wp_get_global_settings( array( 'appearanceTools' ) ), 'custom_colors' => (bool) wp_get_global_settings( array( 'color', 'custom' ) ), ), 'blocks' => $this->registered_block_support(), 'fetched_at' => gmdate( 'c' ), ), 200 ); } /** * Flatten one theme.json settings array into `{slug, name, value, origin}`. * * `wp_get_global_settings()` returns colours grouped by origin (`default`, * `blocks`, `theme`, `custom`) when a site has more than one, and a flat * list when it does not, so both shapes are handled. Origin travels with * each entry because a theme's own colour is a better choice than a core * default, and a slug defined in more than one origin reports the value * WordPress itself resolves — the owner's `custom` over the theme's. * * @param array $path Settings path, e.g. ['color','palette']. * @param string $value_key Key holding the literal value. * @return array> */ private function global_setting_slugs( array $path, string $value_key ): array { if ( ! function_exists( 'wp_get_global_settings' ) ) { return array(); } $settings = wp_get_global_settings( $path ); if ( ! is_array( $settings ) || empty( $settings ) ) { return array(); } $grouped = array(); foreach ( self::PRESET_ORIGINS as $origin ) { if ( isset( $settings[ $origin ] ) && is_array( $settings[ $origin ] ) ) { $grouped[ $origin ] = $settings[ $origin ]; } } if ( empty( $grouped ) ) { $grouped = array( 'theme' => $settings ); } // Ascending priority, last one wins per slug — the same rule // WP_Theme_JSON::get_settings_values_by_slug() applies when it builds the // preset variables, so a slug the owner redefined reports the value the // site actually renders rather than the one the theme shipped. $by_slug = array(); foreach ( $grouped as $origin => $items ) { foreach ( (array) $items as $item ) { if ( ! is_array( $item ) || empty( $item['slug'] ) ) { continue; } $slug = sanitize_title( (string) $item['slug'] ); if ( '' === $slug ) { continue; } if ( ! isset( $by_slug[ $slug ] ) && count( $by_slug ) >= 100 ) { continue; } $by_slug[ $slug ] = array( 'slug' => $slug, 'name' => sanitize_text_field( (string) ( $item['name'] ?? $item['slug'] ) ), 'value' => sanitize_text_field( (string) ( $item[ $value_key ] ?? '' ) ), 'origin' => sanitize_key( (string) $origin ), ); } } return array_values( $by_slug ); } /** * Content and wide widths, which decide whether a wide-aligned block has * anywhere to expand into. * * @return array */ private function global_layout_settings(): array { $layout = function_exists( 'wp_get_global_settings' ) ? wp_get_global_settings( array( 'layout' ) ) : array(); if ( ! is_array( $layout ) ) { $layout = array(); } return array( 'content_size' => sanitize_text_field( (string) ( $layout['contentSize'] ?? '' ) ), 'wide_size' => sanitize_text_field( (string) ( $layout['wideSize'] ?? '' ) ), ); } /** * Which core blocks this WordPress registers, and the style variations each * one offers. `core/details` exists from WP 6.3 and `is-style-stripes` is a * table style a theme may unregister — generated content consults this * instead of assuming. * * @return array> */ private function registered_block_support(): array { if ( ! class_exists( '\WP_Block_Type_Registry' ) ) { return array(); } $blocks = array(); foreach ( \WP_Block_Type_Registry::get_instance()->get_all_registered() as $name => $block_type ) { if ( 0 !== strpos( (string) $name, 'core/' ) ) { continue; } $styles = array(); foreach ( (array) ( $block_type->styles ?? array() ) as $style ) { if ( is_array( $style ) && ! empty( $style['name'] ) ) { $styles[] = sanitize_title( (string) $style['name'] ); } } if ( class_exists( '\WP_Block_Styles_Registry' ) ) { foreach ( \WP_Block_Styles_Registry::get_instance()->get_registered_styles_for_block( (string) $name ) as $style ) { if ( is_array( $style ) && ! empty( $style['name'] ) ) { $slug = sanitize_title( (string) $style['name'] ); if ( ! in_array( $slug, $styles, true ) ) { $styles[] = $slug; } } } } $blocks[] = array( 'name' => sanitize_text_field( (string) $name ), 'styles' => $styles, ); } return $blocks; } /** * GET /site-structure — registered public post types (with counts), public * taxonomies, the page hierarchy (parent/child), and menu locations. */ public function handle_site_structure( \WP_REST_Request $request ): \WP_REST_Response { $post_types = array(); foreach ( $this->offered_public_post_types() as $pt ) { $counts = wp_count_posts( $pt->name ); $post_types[] = array( 'name' => $pt->name, 'label' => sanitize_text_field( (string) $pt->label ), 'count' => isset( $counts->publish ) ? (int) $counts->publish : 0, 'create_allowed' => current_user_can( ! empty( $pt->cap->create_posts ) ? $pt->cap->create_posts : $pt->cap->edit_posts ), ); } $taxonomies = array(); foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $tax ) { $taxonomies[] = array( 'name' => $tax->name, 'label' => sanitize_text_field( (string) $tax->label ), ); } $pages = array(); foreach ( get_pages( array( 'sort_column' => 'menu_order,post_title' ) ) as $page ) { if ( ! $page instanceof \WP_Post ) { continue; } $pages[] = array( 'id' => $page->ID, 'title' => sanitize_text_field( get_the_title( $page ) ), 'slug' => $page->post_name, 'parent' => (int) $page->post_parent, ); } $menus = array(); $locations = get_nav_menu_locations(); foreach ( get_registered_nav_menus() as $location => $description ) { $assigned = isset( $locations[ $location ] ) ? (int) $locations[ $location ] : 0; $menu = $assigned > 0 ? wp_get_nav_menu_object( $assigned ) : false; $menus[] = array( 'location' => sanitize_key( $location ), 'description' => sanitize_text_field( (string) $description ), 'menu_name' => $menu ? sanitize_text_field( $menu->name ) : null, ); } return new \WP_REST_Response( array( 'post_types' => $post_types, 'taxonomies' => $taxonomies, 'pages' => $pages, 'menus' => $menus, ), 200 ); } /** * GET /menus — enumerate actual nav menus, their assigned locations, and a * structural fingerprint used to fence preview/execute drift. */ public function handle_list_menus( \WP_REST_Request $request ): \WP_REST_Response { $locations = get_nav_menu_locations(); $menus = array(); $terms = wp_get_nav_menus(); if ( is_wp_error( $terms ) ) { return new \WP_REST_Response( array( 'menus' => array(), 'error' => 'Menus could not be read from WordPress.', ), 502 ); } foreach ( $terms as $menu ) { $menu_id = (int) $menu->term_id; $assigned_locations = array(); foreach ( $locations as $location => $assigned_menu_id ) { if ( (int) $assigned_menu_id === $menu_id ) { $assigned_locations[] = sanitize_key( (string) $location ); } } $items = wp_get_nav_menu_items( $menu_id ); $items = is_array( $items ) ? $items : array(); $menus[] = array( 'menu_id' => $menu_id, 'menu_name' => sanitize_text_field( (string) $menu->name ), 'assigned_locations' => $assigned_locations, 'item_count' => count( $items ), 'menu_fingerprint' => $this->menu_fingerprint( $menu_id, $items, $assigned_locations ), ); } return new \WP_REST_Response( array( 'menus' => $menus ), 200 ); } /** * GET /menus/{menu_id} — return the complete ordered menu item tree. */ public function handle_get_menu( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $menu_id = (int) $request->get_param( 'menu_id' ); $menu = wp_get_nav_menu_object( $menu_id ); if ( ! $menu ) { return new \WP_Error( 'wppilot_menu_not_found', 'Menu not found.', array( 'status' => 404 ) ); } $locations = $this->menu_locations( $menu_id ); $items = wp_get_nav_menu_items( $menu_id ); $items = is_array( $items ) ? $items : array(); $payload = array(); foreach ( $items as $item ) { $payload[] = $this->menu_item_payload( $item, $locations, $menu_id ); } return new \WP_REST_Response( array( 'menu_id' => $menu_id, 'menu_name' => sanitize_text_field( (string) $menu->name ), 'assigned_locations' => $locations, 'items' => $payload, 'menu_fingerprint' => $this->menu_fingerprint( $menu_id, $items, $locations ), ), 200 ); } /** * GET /menus/{menu_id}/items/{item_id} — return one item and its structural * context for targeted readback. */ public function handle_get_menu_item( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $menu_id = (int) $request->get_param( 'menu_id' ); $item_id = (int) $request->get_param( 'item_id' ); $menu = wp_get_nav_menu_object( $menu_id ); if ( ! $menu ) { return new \WP_Error( 'wppilot_menu_not_found', 'Menu not found.', array( 'status' => 404 ) ); } $item = $this->get_menu_item( $menu_id, $item_id ); if ( ! $item ) { return new \WP_Error( 'wppilot_menu_item_not_found', 'Menu item not found in this menu.', array( 'status' => 404 ) ); } $locations = $this->menu_locations( $menu_id ); $items = wp_get_nav_menu_items( $menu_id ); $items = is_array( $items ) ? $items : array(); return new \WP_REST_Response( array( 'menu_id' => $menu_id, 'menu_name' => sanitize_text_field( (string) $menu->name ), 'assigned_locations' => $locations, 'item' => $this->menu_item_payload( $item, $locations, $menu_id ), 'menu_fingerprint' => $this->menu_fingerprint( $menu_id, $items, $locations ), ), 200 ); } /** * POST /menus/{menu_id}/items/{item_id} — update only an existing item's * label and/or URL, guarded by the full captured menu/item structure. */ public function handle_update_menu_item( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $menu_id = (int) $request->get_param( 'menu_id' ); $item_id = (int) $request->get_param( 'item_id' ); $menu = wp_get_nav_menu_object( $menu_id ); if ( ! $menu ) { return new \WP_Error( 'wppilot_menu_not_found', 'Menu not found.', array( 'status' => 404 ) ); } if ( ! current_user_can( 'edit_theme_options' ) ) { return new \WP_Error( 'rest_forbidden', 'Insufficient permissions to edit menus.', array( 'status' => 403 ) ); } $item = $this->get_menu_item( $menu_id, $item_id ); if ( ! $item ) { return new \WP_Error( 'wppilot_menu_item_not_found', 'Menu item not found in this menu.', array( 'status' => 404 ) ); } $locations = $this->menu_locations( $menu_id ); $items = wp_get_nav_menu_items( $menu_id ); $items = is_array( $items ) ? $items : array(); $before = $this->menu_item_payload( $item, $locations, $menu_id ); $expected_menu = sanitize_text_field( (string) $request->get_param( 'expected_menu_fingerprint' ) ); $expected_item = sanitize_text_field( (string) $request->get_param( 'expected_item_fingerprint' ) ); $actual_menu = $this->menu_fingerprint( $menu_id, $items, $locations ); $actual_item = (string) $before['item_fingerprint']; if ( '' === $expected_menu || '' === $expected_item ) { return new \WP_Error( 'wppilot_drift_guard_required', 'Expected menu and item fingerprints are required.', array( 'status' => 400 ) ); } if ( ! hash_equals( $expected_menu, $actual_menu ) || ! hash_equals( $expected_item, $actual_item ) ) { return new \WP_Error( 'wppilot_state_drift', 'The menu changed since preview; re-read it before editing.', array( 'status' => 409 ) ); } $title_present = null !== $request->get_param( 'title' ); $url_present = null !== $request->get_param( 'url' ); if ( ! $title_present && ! $url_present ) { return new \WP_Error( 'wppilot_bad_request', 'At least one of title or url is required.', array( 'status' => 400 ) ); } $title = $title_present ? sanitize_text_field( (string) $request->get_param( 'title' ) ) : (string) $item->title; $url = $url_present ? esc_url_raw( (string) $request->get_param( 'url' ) ) : (string) $item->url; if ( $title_present && '' === $title ) { return new \WP_Error( 'wppilot_bad_request', 'Title cannot be empty.', array( 'status' => 400 ) ); } if ( $url_present && '' === $url ) { return new \WP_Error( 'wppilot_bad_request', 'URL cannot be empty.', array( 'status' => 400 ) ); } if ( $url_present && 'custom' !== (string) $item->type && $url !== (string) $item->url ) { return new \WP_Error( 'wppilot_menu_item_type_mismatch', 'Only custom-link items can be repointed by URL in this release.', array( 'status' => 422 ) ); } $writes = self::menu_item_field_writes( (string) get_post_field( 'post_title', $item_id, 'raw' ), (string) get_post_meta( $item_id, '_menu_item_url', true ), $this->menu_item_inherited_title( $item ), $title_present ? $title : null, $url_present ? $url : null ); $result = $this->write_menu_item_fields( $item_id, $writes ); if ( is_wp_error( $result ) ) { return $result; } $updated = $this->get_menu_item( $menu_id, $item_id ); if ( ! $updated ) { return new \WP_Error( 'wppilot_menu_item_not_found', 'Updated menu item could not be read back.', array( 'status' => 502 ) ); } $items = wp_get_nav_menu_items( $menu_id ); $items = is_array( $items ) ? $items : array(); // A menu is on every page; a post-scoped purge cannot reach what changed. $cache = $this->cache_purger->purge_menu_cache( $menu_id ); return new \WP_REST_Response( array( 'menu_id' => $menu_id, 'item_id' => $item_id, 'before' => $before, 'after' => $this->menu_item_payload( $updated, $locations, $menu_id ), 'menu_fingerprint' => $this->menu_fingerprint( $menu_id, $items, $locations ), 'cache' => $cache, ), 200 ); } /** * The stored fields a label or link change writes — those, and nothing else. * * The write used to go through `wp_update_nav_menu_item`, which re-saves * EVERY field of the item from its arguments and defaults whatever is absent. * Passing each field back as stored (1.5.25) closed the two that moved the * item out of its submenu, and left the class open: the undo-fingerprint gate * (2026-10-17) found the same re-save moving `post_date` to now * (`wp_resolve_post_date` of an absent date) and re-positioning an item stored * at `menu_order` 0, which WordPress reads as "append". Undo went through the * same re-save, so neither came back. Writing only the addressed columns * leaves every other field exactly as stored, by construction rather than by * passing each one back. * * The label follows WordPress's own rule for an item that points at a page, * term or archive: a label equal to that object's own title is stored empty, * so the item keeps following the object (`wp_update_nav_menu_item` does the * same on every save). That is also what makes an undo back to the inherited * label exact. * * @param string $stored_title The item post's own `post_title`. * @param string $stored_url The item's `_menu_item_url` meta. * @param string|null $inherited_title The pointed-at object's title, or null for a custom link. * @param string|null $title The requested label, or null when not addressed. * @param string|null $url The requested link, or null when not addressed. * @return array `post_title` and/or `_menu_item_url`, only when they change. */ public static function menu_item_field_writes( string $stored_title, string $stored_url, ?string $inherited_title, ?string $title, ?string $url ): array { $writes = array(); if ( null !== $title ) { $to_store = ( null !== $inherited_title && $title === wp_specialchars_decode( $inherited_title ) ) ? '' : $title; if ( $to_store !== $stored_title ) { $writes['post_title'] = $to_store; } } if ( null !== $url && $url !== $stored_url ) { $writes['_menu_item_url'] = $url; } return $writes; } /** * The title WordPress shows for an item whose own label is empty, or null * for a custom link, which has no object to inherit from. * * The same lookup `wp_update_nav_menu_item` makes before it blanks a label. */ private function menu_item_inherited_title( object $item ): ?string { $type = (string) ( $item->type ?? '' ); $object = (string) ( $item->object ?? '' ); $object_id = (int) ( $item->object_id ?? 0 ); if ( 'post_type' === $type ) { $post = get_post( $object_id ); return $post ? (string) $post->post_title : null; } if ( 'taxonomy' === $type ) { $name = get_term_field( 'name', $object_id, $object, 'raw' ); return is_string( $name ) ? $name : null; } if ( 'post_type_archive' === $type ) { $post_type = get_post_type_object( $object ); return $post_type ? (string) $post_type->labels->archives : null; } return null; } /** * Write the columns {@see menu_item_field_writes} named, and only those. * * The label goes through the site's own title filters (`title_save_pre`, * which carries kses for a user without `unfiltered_html`) and is written to * its column directly, with the modified time, so nothing else in the row is * re-derived. `clean_post_cache` is what tells caches the item changed; the * caller purges page caches. * * @param array $writes * @return true|\WP_Error */ private function write_menu_item_fields( int $item_id, array $writes ): bool|\WP_Error { global $wpdb; if ( array_key_exists( 'post_title', $writes ) ) { $title = wp_unslash( (string) sanitize_post_field( 'post_title', wp_slash( $writes['post_title'] ), $item_id, 'db' ) ); // One column of one row, by design (see above); clean_post_cache() follows. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $written = $wpdb->update( $wpdb->posts, array( 'post_title' => $title, 'post_modified' => current_time( 'mysql' ), 'post_modified_gmt' => current_time( 'mysql', true ), ), array( 'ID' => $item_id ), array( '%s', '%s', '%s' ), array( '%d' ) ); clean_post_cache( $item_id ); if ( false === $written ) { return new \WP_Error( 'wppilot_menu_item_write_failed', 'WordPress did not save the menu item label.', array( 'status' => 500 ) ); } } if ( array_key_exists( '_menu_item_url', $writes ) ) { update_post_meta( $item_id, '_menu_item_url', wp_slash( $writes['_menu_item_url'] ) ); if ( (string) get_post_meta( $item_id, '_menu_item_url', true ) !== $writes['_menu_item_url'] ) { return new \WP_Error( 'wppilot_menu_item_write_failed', 'WordPress did not save the menu item link.', array( 'status' => 500 ) ); } } return true; } /** * GET /cron-events — scheduled WP-Cron events: hook, next run, recurrence, * and a best-effort registered_by. Basic listing only (deeper cron analytics * are deferred to V1.2 per the brief). */ public function handle_cron_events( \WP_REST_Request $request ): \WP_REST_Response { $crons = _get_cron_array(); $schedules = wp_get_schedules(); $events = array(); if ( is_array( $crons ) ) { foreach ( $crons as $timestamp => $hooks ) { if ( ! is_array( $hooks ) ) { continue; } foreach ( $hooks as $hook => $instances ) { if ( ! is_array( $instances ) ) { continue; } $recurrence = 'single'; foreach ( $instances as $instance ) { if ( is_array( $instance ) && ! empty( $instance['schedule'] ) ) { $schedule_key = (string) $instance['schedule']; $recurrence = isset( $schedules[ $schedule_key ]['display'] ) ? sanitize_text_field( (string) $schedules[ $schedule_key ]['display'] ) : $schedule_key; } break; } $events[] = array( 'hook' => sanitize_text_field( (string) $hook ), 'next_run' => gmdate( 'c', (int) $timestamp ), 'recurrence' => $recurrence, 'registered_by' => $this->cron_origin( (string) $hook ), ); } } } return new \WP_REST_Response( array( 'events' => $events ), 200 ); } /* ---- Layer 3 helpers ------------------------------------------------ */ /** * The set of public post types the read tools may surface. Always includes * post + page; adds any other public custom post type the site registers. * * @return array */ private function public_post_types(): array { $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); $names = array_values( $types ); if ( ! in_array( 'post', $names, true ) ) { $names[] = 'post'; } if ( ! in_array( 'page', $names, true ) ) { $names[] = 'page'; } return $names; } /** Clamp a request param to an int within [min,max], defaulting when unset. */ private function clamp_int( $value, int $default, int $min, int $max ): int { if ( null === $value || ! is_numeric( $value ) ) { return $default; } $n = (int) $value; return max( $min, min( $max, $n ) ); } /** * Compact list-item view of a post. * * @return array */ private function post_summary( \WP_Post $post ): array { return array( 'post_id' => $post->ID, 'title' => sanitize_text_field( get_the_title( $post ) ), 'slug' => $post->post_name, 'status' => $post->post_status, 'post_type' => $post->post_type, 'modified' => gmdate( 'c', (int) strtotime( $post->post_modified_gmt . ' UTC' ) ), /* * The PUBLISH date, which this view never carried. * * Only `modified` was sent, so "my posts from 2024" had no field to * filter on and "when does that scheduled post go live?" had no * field to answer from — the latter was reachable only by accident, * through the cron endpoint's `publish_future_post` hook. For a * scheduled post this is the future go-live time, which is exactly * what makes it worth sending. */ 'date' => gmdate( 'c', (int) strtotime( $post->post_date_gmt . ' UTC' ) ), 'author' => sanitize_text_field( (string) get_the_author_meta( 'display_name', (int) $post->post_author ) ), 'url' => get_permalink( $post ), ); } /** * Full detail view of a post for read_post / get_post_meta. * * @return array */ private function post_detail( \WP_Post $post ): array { $plugin = $this->seo->detect(); $meta_key = $this->seo->meta_key_for( $plugin ); $meta = $this->meta_value_to_string( get_post_meta( $post->ID, $meta_key, true ) ) ?? ''; $categories = array(); $tags = array(); if ( 'post' === $post->post_type ) { foreach ( get_the_category( $post->ID ) as $cat ) { $categories[] = sanitize_text_field( $cat->name ); } $post_tags = get_the_tags( $post->ID ); if ( is_array( $post_tags ) ) { foreach ( $post_tags as $tag ) { $tags[] = sanitize_text_field( $tag->name ); } } } $thumb = get_the_post_thumbnail_url( $post->ID, 'full' ); return array( 'post_id' => $post->ID, 'title' => sanitize_text_field( get_the_title( $post ) ), 'slug' => $post->post_name, 'content' => wp_kses_post( $post->post_content ), 'excerpt' => sanitize_textarea_field( $post->post_excerpt ), 'meta_description' => sanitize_text_field( $meta ), 'seo_plugin_detected' => $plugin, 'status' => $post->post_status, 'post_type' => $post->post_type, 'modified' => gmdate( 'c', (int) strtotime( $post->post_modified_gmt . ' UTC' ) ), 'date' => gmdate( 'c', (int) strtotime( $post->post_date_gmt . ' UTC' ) ), 'author' => sanitize_text_field( (string) get_the_author_meta( 'display_name', (int) $post->post_author ) ), 'featured_image_url' => is_string( $thumb ) ? $thumb : null, 'categories' => $categories, 'tags' => $tags, 'url' => get_permalink( $post ), /* * The post's custom field values (1.5.19). A site built on custom * fields keeps its content here, not in the body: alaskaholiday.co's * tours have an empty body and their itinerary in Pods fields, and * a read without them reported every tour's itinerary as blank. * Same permission as the raw read: a user who may edit the post. */ 'custom_fields' => current_user_can( 'edit_post', $post->ID ) ? Custom_Fields::for_post( $post ) : array( 'withheld' => 'The connected user cannot edit this post, so its custom fields are not read.' ), ); } /** A short plain-text snippet around the search term (excerpt or content). */ private function snippet( \WP_Post $post, string $term ): string { $source = '' !== $post->post_excerpt ? $post->post_excerpt : $post->post_content; $text = wp_strip_all_tags( strip_shortcodes( $source ) ); $text = trim( preg_replace( '/\s+/', ' ', $text ) ?? '' ); if ( '' === $text ) { return ''; } $pos = stripos( $text, $term ); if ( false === $pos ) { return sanitize_text_field( mb_substr( $text, 0, 160 ) ); } $start = max( 0, $pos - 60 ); $snippet = mb_substr( $text, $start, 180 ); if ( $start > 0 ) { $snippet = '…' . $snippet; } return sanitize_text_field( $snippet ); } /** * Best-effort origin of a cron hook. WordPress does not record which plugin * scheduled a hook, so we only confidently name core hooks; everything else * is reported as 'unknown' rather than guessed. */ private function cron_origin( string $hook ): string { $core_hooks = array( 'wp_version_check', 'wp_update_plugins', 'wp_update_themes', 'wp_scheduled_delete', 'wp_scheduled_auto_draft_delete', 'delete_expired_transients', 'wp_privacy_delete_old_export_files', 'recovery_mode_clean_expired_keys', 'wp_https_detection', 'wp_update_user_counts', ); return in_array( $hook, $core_hooks, true ) ? 'core' : 'unknown'; } /** * Heartbeat: plugin/environment metadata for connectivity verification, plus * the abilities inventory and the abilities_api_available flag (brief Part 4.6 * + the graceful-degradation fork). Version detection is logged at info level * each cycle so the backend's view of the site's Abilities API support is * observable. */ /* ==================================================================== * * WATCHTOWER SCAN DATA ENDPOINTS (PR #24) * * * * Three read-only endpoints for the backend's Watchtower scan engine. * * The plugin is a thin data layer: enumerate files + return content. * * Engines run on the backend in TypeScript. Lite-eligible (no writes). * * All paginated to stay under 10s per request on shared hosts. * * ==================================================================== */ /** * The scan surface is owned by {@see Scan_Policy}, not by this controller. * Both scan endpoints ask the same class the same questions, so neither can * come to a different conclusion about the same file. */ /** * GET /scan/file-hashes — paginated file hash enumeration. * Walks the WordPress filesystem, stat() + md5_file() each scannable file. * Returns {files: [{path, mtime, size, md5}], cursor, total}. * * @param \WP_REST_Request $request The request with ?limit= and ?cursor= params. */ public function handle_scan_file_hashes( \WP_REST_Request $request ): \WP_REST_Response { $limit = min( (int) ( $request->get_param( 'limit' ) ?? 500 ), 1000 ); $cursor = $this->decode_scan_cursor( (string) ( $request->get_param( 'cursor' ) ?? '' ) ); $files = array(); $errors = array(); $count = 0; $past_cursor = ( '' === $cursor ); $next_cursor = null; $total = 0; // The cursor names the LAST file a page returned. It used to name the // first file a page did not return, and the next page skipped the // cursor file itself, so one file at every page boundary was never // listed and never scanned. $last_returned = null; $root = ABSPATH; try { $directory_iterator = new \RecursiveDirectoryIterator( $root, \FilesystemIterator::SKIP_DOTS ); } catch ( \Throwable $e ) { return new \WP_REST_Response( array( 'files' => $files, 'cursor' => $next_cursor, 'total' => $total, 'errors' => array( 'Cannot read site root: ' . $e->getMessage() ), ), 200 ); } $iterator = new \RecursiveIteratorIterator( $directory_iterator, \RecursiveIteratorIterator::LEAVES_ONLY ); try { foreach ( $iterator as $file ) { try { if ( ! $file->isFile() ) { continue; } $path = $file->getPathname(); if ( ! Scan_Policy::is_scannable( $path ) ) { continue; } // Skip files > 10 MB. $size = $file->getSize(); if ( $size > 10 * 1024 * 1024 ) { continue; } $total++; // Cursor-based pagination: skip until we pass the cursor path. if ( ! $past_cursor ) { if ( $path === $cursor ) { $past_cursor = true; } continue; } if ( $count >= $limit ) { $next_cursor = null === $last_returned ? null : $this->encode_scan_cursor( $last_returned ); break; } $md5 = md5_file( $path ); if ( false === $md5 ) { continue; } // SHA-1 of the original bytes travels with the fingerprint so // known-malware hash matching keeps working on files whose // transmitted content was redacted. A fingerprint proves what // the file is without disclosing what it holds. $sha1 = sha1_file( $path ); if ( false === $sha1 ) { continue; } $rel_path = str_replace( $root, '/', $path ); $files[] = array( 'path' => $rel_path, 'mtime' => $file->getMTime(), 'size' => $size, 'md5' => $md5, 'sha1' => $sha1, // Enumerated and fingerprinted, but its bytes stay here. 'content_withheld' => ! Scan_Policy::transmits_content( $path ), ); $count++; $last_returned = $path; } catch ( \Throwable $e ) { // Log and skip unreadable/problematic files instead of crashing // the whole endpoint. Returning 200 with errors lets the // backend continue the scan with whatever was collected. $errors[] = 'Skipping path: ' . $e->getMessage(); } } } catch ( \Throwable $e ) { $errors[] = 'Enumeration stopped: ' . $e->getMessage(); } // A cursor that was never reached means the file it named is gone. An // empty page with no cursor would read as "listing complete"; saying so // lets the scan start the listing again instead. $cursor_lost = ( '' !== $cursor && ! $past_cursor ); if ( $cursor_lost ) { $errors[] = 'The listing changed between pages; start again.'; } return new \WP_REST_Response( array( 'files' => $files, 'cursor' => $next_cursor, 'total' => $total, 'errors' => $errors, 'cursor_lost' => $cursor_lost, ), 200 ); } /** * The listing cursor travels as an opaque token, never a server path. * * It used to be the absolute path of a file — `?cursor=/home/sites/26b/…` — * and hosts whose firewall rejects a server path in a query string answered * the second page with 403, so the scan of the whole site failed. Encoded * relative to the site root and base64url'd, it carries no slash, no server * layout and nothing a firewall matches. A legacy absolute cursor is still * accepted, so a scan already in progress across an update continues. * * @param string $absolute_path Absolute path of the last file returned. * @return string Opaque cursor. */ private function encode_scan_cursor( string $absolute_path ): string { $relative = ltrim( substr( $absolute_path, strlen( ABSPATH ) ), '/' ); return 'c1.' . rtrim( strtr( base64_encode( $relative ), '+/', '-_' ), '=' ); } /** * Absolute path a cursor names, or '' for none / an unreadable one. * * @param string $cursor Cursor as received. * @return string */ private function decode_scan_cursor( string $cursor ): string { if ( '' === $cursor ) { return ''; } if ( 0 === strpos( $cursor, 'c1.' ) ) { $encoded = strtr( substr( $cursor, 3 ), '-_', '+/' ); $relative = base64_decode( $encoded . str_repeat( '=', ( 4 - strlen( $encoded ) % 4 ) % 4 ), true ); if ( false === $relative || false !== strpos( $relative, '..' ) ) { return ''; } return ABSPATH . $relative; } // Legacy absolute cursor from a 1.4.7 page. return $cursor; } /** * POST /scan/file-contents — return scannable content for requested paths. * Body: {paths: string[]} (max 50 per request). * Returns {files: [{path, content, md5, sha1, redactions, skipped?, reason?}]}. * * Three things bound what this can return, all of them owned by * {@see Scan_Policy} so the enumeration endpoint cannot disagree: * * 1. The path must resolve inside the site root on a directory boundary. * 2. The file must be on the scan surface and not content-withheld. * 3. Credential material is removed before the bytes leave the site. * * The fingerprints are of the ORIGINAL bytes, so known-malware hash * matching is unaffected by redaction: the service learns exactly what the * file is without ever receiving what it protects. * * @param \WP_REST_Request $request The request with JSON body. */ public function handle_scan_file_contents( \WP_REST_Request $request ): \WP_REST_Response { $body = $request->get_json_params(); $paths = is_array( $body ) ? ( $body['paths'] ?? array() ) : array(); if ( ! is_array( $paths ) ) { return new \WP_REST_Response( array( 'error' => 'paths must be an array' ), 400 ); } // Cap at 50 per request. $paths = array_slice( $paths, 0, 50 ); $files = array(); foreach ( $paths as $rel_path ) { if ( ! is_string( $rel_path ) ) { continue; } $real = Scan_Policy::resolve_within_root( $rel_path ); if ( false === $real ) { $files[] = $this->scan_skip( $rel_path, 'outside_site_root' ); continue; } // The same surface the enumeration endpoint reports. Asking for a // path directly can no longer reach what enumeration would not list. if ( ! Scan_Policy::is_scannable( $real ) ) { $files[] = $this->scan_skip( $rel_path, 'not_scannable' ); continue; } if ( ! is_file( $real ) ) { $files[] = $this->scan_skip( $rel_path, 'not_found' ); continue; } // Content stays here — but the file is still read, and what it // contains is still reported. Inspection is not transmission. if ( ! Scan_Policy::transmits_content( $real ) ) { $size = filesize( $real ); $skip = $this->scan_skip( $rel_path, 'content_withheld' ); if ( false !== $size && $size <= 2 * 1024 * 1024 ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local read for the security scanner; WP_Filesystem is not initialised on REST requests. $local = file_get_contents( $real ); if ( false !== $local ) { $skip['md5'] = md5( $local ); $skip['sha1'] = sha1( $local ); $skip['size'] = strlen( $local ); $skip['observations'] = Scan_Policy::observe( $local, $real ); } } $files[] = $skip; continue; } // Skip files > 2 MB. $size = filesize( $real ); if ( false === $size || $size > 2 * 1024 * 1024 ) { $files[] = $this->scan_skip( $rel_path, 'too_large' ); continue; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local file read for the security scanner; WP_Filesystem is not initialised on REST requests. $content = file_get_contents( $real ); if ( false === $content ) { $files[] = $this->scan_skip( $rel_path, 'read_error' ); continue; } $sanitized = Scan_Policy::sanitize( $content, $real ); $files[] = array( 'path' => $rel_path, 'content' => $sanitized['content'], 'md5' => md5( $content ), 'sha1' => sha1( $content ), 'redactions' => $sanitized['redactions'], // Observations belong to files whose content stayed here; a // transmitted file is analysed by the service itself. 'observations' => array(), 'skipped' => false, 'reason' => null, ); } return new \WP_REST_Response( array( 'files' => $files ), 200 ); } /** * One shape for every path this endpoint declines to return, so a caller * never has to distinguish "empty file" from "we would not send it". * * @param string $rel_path The path as the caller asked for it. * @param string $reason Machine-readable reason. * @return array */ private function scan_skip( string $rel_path, string $reason ): array { return array( 'path' => $rel_path, 'content' => '', 'md5' => null, 'sha1' => null, 'redactions' => 0, 'observations' => array(), 'skipped' => true, 'reason' => $reason, ); } /** * The site's trash settings, which decide whether an undo through the * trash can work: `EMPTY_TRASH_DAYS` (0 = "trash" deletes for good; core * defines it, 30 by default) and `MEDIA_TRASH` (off = an attachment is * deleted rather than trashed). * * @return array{empty_trash_days:int,media_trash:bool} */ private static function trash_settings(): array { return array( 'empty_trash_days' => defined( 'EMPTY_TRASH_DAYS' ) ? max( 0, (int) EMPTY_TRASH_DAYS ) : 30, 'media_trash' => defined( 'MEDIA_TRASH' ) && MEDIA_TRASH, ); } public function handle_heartbeat( \WP_REST_Request $request ): \WP_REST_Response { $inventory = $this->abilities->inventory(); $this->logger->info( sprintf( 'Heartbeat: WP %s, Abilities API %s (%d abilities)', get_bloginfo( 'version' ), $inventory['available'] ? 'available' : 'unavailable', count( $inventory['abilities'] ) ) ); return new \WP_REST_Response( array( 'status' => 'ok', 'plugin_version' => WPPILOT_VERSION, 'features' => self::features(), 'wp_version' => get_bloginfo( 'version' ), 'php_version' => PHP_VERSION, 'site_url' => get_site_url(), 'timezone' => wp_timezone_string(), 'abilities_api_available' => $inventory['available'], 'abilities' => $inventory['abilities'], ), 200 ); } /** * Return the locations currently assigned to a menu ID. * * @return string[] */ private function menu_locations( int $menu_id ): array { $assigned = array(); foreach ( get_nav_menu_locations() as $location => $assigned_menu_id ) { if ( (int) $assigned_menu_id === $menu_id ) { $assigned[] = sanitize_key( (string) $location ); } } return $assigned; } private function get_menu_item( int $menu_id, int $item_id ): ?\WP_Post { $items = wp_get_nav_menu_items( $menu_id ); foreach ( is_array( $items ) ? $items : array() as $item ) { if ( (int) ( $item->db_id ?? $item->ID ?? 0 ) === $item_id ) { return $item; } } return null; } /** * Serialize the fields whose change would invalidate a reviewed menu * structure. The hash deliberately includes identity, hierarchy, order, * target, and presentation fields, not only the label and URL. * * @param \WP_Post|object $item Menu item. * @return array */ private function menu_item_payload( object $item, array $locations = array(), int $menu_id = 0 ): array { $classes = is_array( $item->classes ?? null ) ? $item->classes : array(); $state = array( 'item_id' => (int) ( $item->ID ?? $item->db_id ?? 0 ), 'menu_id' => $menu_id > 0 ? $menu_id : (int) ( $item->term_id ?? 0 ), 'title' => sanitize_text_field( (string) ( $item->title ?? '' ) ), 'url' => esc_url_raw( (string) ( $item->url ?? '' ) ), 'object_type' => sanitize_key( (string) ( $item->object ?? '' ) ) ?: null, 'object_id' => (int) ( $item->object_id ?? 0 ) ?: null, 'item_type' => sanitize_key( (string) ( $item->type ?? '' ) ), 'parent_id' => (int) ( $item->menu_item_parent ?? 0 ), 'menu_order' => (int) ( $item->menu_order ?? 0 ), 'target' => sanitize_text_field( (string) ( $item->target ?? '' ) ), 'attr_title' => sanitize_text_field( (string) ( $item->attr_title ?? '' ) ), 'description' => sanitize_textarea_field( (string) ( $item->description ?? '' ) ), 'classes' => array_values( array_map( 'sanitize_html_class', $classes ) ), 'xfn' => sanitize_text_field( (string) ( $item->xfn ?? '' ) ), 'status' => sanitize_key( (string) ( $item->post_status ?? '' ) ), ); $fingerprint = $this->fingerprint( $state ); return array( 'item_id' => $state['item_id'], 'menu_id' => $state['menu_id'], 'title' => $state['title'], 'url' => $state['url'], 'object_type' => $state['object_type'], 'object_id' => $state['object_id'], 'item_type' => $state['item_type'], 'parent_id' => $state['parent_id'], 'menu_order' => $state['menu_order'], 'target' => $state['target'], 'attr_title' => $state['attr_title'], 'description' => $state['description'], 'classes' => $state['classes'], 'xfn' => $state['xfn'], 'status' => $state['status'], // The item's stored dates, so a change records them and its undo can // prove them unchanged. Read, never written; outside the fingerprint so // a card prepared on an earlier release still matches. 'post_date' => (string) ( $item->post_date ?? '' ), 'post_date_gmt' => (string) ( $item->post_date_gmt ?? '' ), 'assigned_locations' => array_values( $locations ), 'item_fingerprint' => $fingerprint, ); } /** * @param object[] $items * @param string[] $locations */ private function menu_fingerprint( int $menu_id, array $items, array $locations ): string { $states = array(); foreach ( $items as $item ) { $payload = $this->menu_item_payload( $item, $locations, $menu_id ); unset( $payload['item_fingerprint'] ); $states[] = $payload; } usort( $states, static function ( array $left, array $right ): int { return (int) $left['menu_order'] <=> (int) $right['menu_order'] ?: (int) $left['item_id'] <=> (int) $right['item_id']; } ); return $this->fingerprint( array( 'menu_id' => $menu_id, 'locations' => array_values( $locations ), 'items' => $states, ) ); } /** * @param array $value */ private function fingerprint( array $value ): string { $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return hash( 'sha256', false === $json ? serialize( $value ) : $json ); } /** * Human-readable label for a post type, falling back to the raw type key. */ private function post_type_label( string $type ): string { $object = get_post_type_object( $type ); if ( $object && '' !== (string) $object->label ) { return sanitize_text_field( (string) $object->label ); } return sanitize_text_field( $type ); } /** * Return owner-facing public post types suitable for structure and creation * discovery. Internal builder/plugin objects are intentionally excluded. * * @return array */ private function offered_public_post_types(): array { $denied_types = array( 'attachment', 'nav_menu_item', 'custom_css', 'customize_changeset', 'elementor_library', 'e-landing-page', 'popup', 'popup_theme', 'wpforms', 'flamingo_inbound', 'shop_order', 'shop_order_refund', 'product_variation', ); $offered = array(); foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $post_type ) { if ( ! $post_type instanceof \WP_Post_Type || empty( $post_type->show_ui ) || '' === trim( (string) $post_type->label ) || in_array( $post_type->name, $denied_types, true ) ) { continue; } $offered[] = $post_type; } return $offered; } /** * Convert postmeta to a safe transport string without PHP's lossy "Array" * cast. Arrays are represented as JSON so callers never mistake corruption * for a legitimate value. */ private function meta_value_to_string( mixed $value ): ?string { if ( is_string( $value ) ) { return $value; } if ( is_array( $value ) || is_object( $value ) ) { $json = wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); return false === $json ? null : $json; } if ( null === $value || is_scalar( $value ) ) { return null === $value ? '' : (string) $value; } return null; } }