POST /rewrites -> poll job -> * render diff modal (current vs proposed per task) -> on Approve, apply via * wp_update_post() so a revision is created. NEVER auto-apply. * date_edits arrive as find/replace pairs - applied only within post_content. */ defined( 'ABSPATH' ) || exit; class Refrank_Rewriter { const CAP = 'manage_options'; const NONCE = 'refrank_admin'; const MAX_EXCERPT = 30000; // matches backend 30KB cap. const DEFAULT_TASKS = array( 'title', 'meta', 'intro', 'faq', 'dates' ); public static function init() { add_action( 'wp_ajax_refrank_request_rewrite', array( __CLASS__, 'ajax_request' ) ); add_action( 'wp_ajax_refrank_poll_job', array( __CLASS__, 'ajax_poll' ) ); add_action( 'wp_ajax_refrank_apply_rewrite', array( __CLASS__, 'ajax_apply' ) ); } /** * Capability check only - every AJAX handler calls check_ajax_referer() * inline first, so the nonce check is visible in the same scope as the * $_POST reads (and to static analysis). */ private static function guard() { if ( ! current_user_can( self::CAP ) ) { wp_send_json_error( array( 'message' => __( 'Not allowed.', 'refreshrank' ) ), 403 ); } } private static function fail( $wp_error ) { wp_send_json_error( array( 'code' => $wp_error->get_error_code(), 'message' => $wp_error->get_error_message(), ) ); } // --- request --------------------------------------------------------- public static function ajax_request() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $post = $post_id ? get_post( $post_id ) : null; if ( ! $post ) { wp_send_json_error( array( 'message' => __( 'Post not found.', 'refreshrank' ) ) ); } $tasks = isset( $_POST['tasks'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['tasks'] ) ) : self::DEFAULT_TASKS; $tasks = array_values( array_intersect( $tasks, self::DEFAULT_TASKS ) ); $queries = array(); if ( isset( $_POST['queries'] ) ) { // json_decode() does not sanitize; every decoded element is validated // as a scalar and sanitized in the loop below before it is used. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- decoded values sanitized below. $decoded = json_decode( wp_unslash( $_POST['queries'] ), true ); if ( is_array( $decoded ) ) { foreach ( $decoded as $query ) { if ( is_scalar( $query ) ) { $queries[] = sanitize_text_field( (string) $query ); } } } } $current = self::current_content( $post ); $data = Refrank_API_Client::create_rewrite( array( 'site_id' => get_option( 'refrank_site_id', '' ), 'url' => get_permalink( $post ), 'tasks' => $tasks, 'current' => $current, 'top_queries' => $queries, ) ); if ( is_wp_error( $data ) ) { self::fail( $data ); } // Return `current` too so the diff modal can show current vs proposed. wp_send_json_success( array_merge( (array) $data, array( 'current' => $current ) ) ); } public static function ajax_poll() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $job_id = isset( $_POST['job_id'] ) ? sanitize_text_field( wp_unslash( $_POST['job_id'] ) ) : ''; $data = Refrank_API_Client::get_rewrite( $job_id ); if ( is_wp_error( $data ) ) { self::fail( $data ); } wp_send_json_success( $data ); } // --- apply (creates a revision) -------------------------------------- public static function ajax_apply() { check_ajax_referer( self::NONCE, 'nonce' ); self::guard(); $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; $post = $post_id ? get_post( $post_id ) : null; if ( ! $post || ! current_user_can( 'edit_post', $post_id ) ) { wp_send_json_error( array( 'message' => __( 'Cannot edit this post.', 'refreshrank' ) ) ); } // json_decode() does not sanitize. The decoded payload is cleaned and // validated up front by sanitize_payload() before any value is used to // build post_content or post meta (date_edits find/replace included). // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in sanitize_payload(). $raw = isset( $_POST['result'] ) ? json_decode( wp_unslash( $_POST['result'] ), true ) : null; $result = self::sanitize_payload( $raw ); if ( empty( $result ) ) { wp_send_json_error( array( 'message' => __( 'Nothing to apply.', 'refreshrank' ) ) ); } $content = $post->post_content; // date_edits: literal find/replace within content only. Both sides were // run through sanitize_text_field() in sanitize_payload(). if ( ! empty( $result['date_edits'] ) ) { foreach ( $result['date_edits'] as $edit ) { $content = str_replace( $edit['find'], $edit['replace'], $content ); } } // intro: replace the first paragraph block, else prepend. Use a callback // so $ / backslash sequences in the (already wp_kses_post'd) HTML are not // interpreted as preg_replace backreferences. if ( ! empty( $result['intro_html'] ) ) { $intro = $result['intro_html']; if ( preg_match( '/
]*>.*?<\/p>/is', $content ) ) { $content = preg_replace_callback( '/
]*>.*?<\/p>/is', static function () use ( $intro ) { return $intro; }, $content, 1 ); } else { $content = $intro . "\n\n" . $content; } } // faq: append once. if ( ! empty( $result['faq_html'] ) ) { $content .= "\n\n" . $result['faq_html']; } $update = array( 'ID' => $post_id, 'post_content' => $content ); if ( ! empty( $result['title'] ) ) { $update['post_title'] = $result['title']; } // The original must always be recoverable. wp_update_post only snapshots // the NEW state, so we must capture the pre-rewrite state ourselves. if ( wp_revisions_enabled( $post ) ) { // Revisions available: snapshot now if the post has none yet. if ( ! wp_get_post_revisions( $post_id ) ) { wp_save_post_revision( $post_id ); } } else { // Revisions disabled (WP_POST_REVISIONS off, or the post type lacks // revision support): wp_save_post_revision() would no-op and the // original would be lost. Keep a single-slot recovery copy in meta // so a rollback is still possible. update_post_meta( $post_id, '_refrank_rollback', wp_slash( wp_json_encode( array( 'post_title' => $post->post_title, 'post_content' => $post->post_content, 'saved_at' => time(), ) ) ) ); } $res = wp_update_post( $update, true ); if ( is_wp_error( $res ) ) { self::fail( $res ); } if ( ! empty( $result['meta'] ) ) { self::apply_meta( $post_id, $result['meta'] ); } wp_send_json_success( array( 'revision_url' => esc_url_raw( get_edit_post_link( $post_id, 'raw' ) ), ) ); } // --- helpers --------------------------------------------------------- /** * Sanitize + validate the decoded rewrite payload before any of it is used * to build post_content or post meta. json_decode() does not sanitize, so * each field is cleaned here with the function appropriate to its context: * plain-text fields (title, meta, date find/replace) via sanitize_text_field(), * HTML fields (intro, faq) via wp_kses_post(). Unknown keys and malformed * entries are dropped. * * @param mixed $raw Decoded JSON - expected to be an associative array. * @return array Clean payload; empty when there is nothing safe to apply. */ private static function sanitize_payload( $raw ) { if ( ! is_array( $raw ) ) { return array(); } $clean = array(); if ( isset( $raw['title'] ) && is_scalar( $raw['title'] ) ) { $title = sanitize_text_field( (string) $raw['title'] ); if ( '' !== $title ) { $clean['title'] = $title; } } if ( isset( $raw['meta'] ) && is_scalar( $raw['meta'] ) ) { $meta = sanitize_text_field( (string) $raw['meta'] ); if ( '' !== $meta ) { $clean['meta'] = $meta; } } if ( isset( $raw['intro_html'] ) && is_scalar( $raw['intro_html'] ) ) { $intro = wp_kses_post( (string) $raw['intro_html'] ); if ( '' !== trim( $intro ) ) { $clean['intro_html'] = $intro; } } if ( isset( $raw['faq_html'] ) && is_scalar( $raw['faq_html'] ) ) { $faq = wp_kses_post( (string) $raw['faq_html'] ); if ( '' !== trim( $faq ) ) { $clean['faq_html'] = $faq; } } if ( ! empty( $raw['date_edits'] ) && is_array( $raw['date_edits'] ) ) { $edits = array(); foreach ( $raw['date_edits'] as $edit ) { if ( ! is_array( $edit ) || ! isset( $edit['find'], $edit['replace'] ) ) { continue; } if ( ! is_scalar( $edit['find'] ) || ! is_scalar( $edit['replace'] ) ) { continue; } $find = sanitize_text_field( (string) $edit['find'] ); $replace = sanitize_text_field( (string) $edit['replace'] ); if ( '' === $find ) { continue; } $edits[] = array( 'find' => $find, 'replace' => $replace, ); } if ( $edits ) { $clean['date_edits'] = $edits; } } return $clean; } private static function current_content( $post ) { $html = $post->post_content; if ( strlen( $html ) > self::MAX_EXCERPT ) { $html = substr( $html, 0, self::MAX_EXCERPT ); } // Raw title, not get_the_title(): that applies the the_title filters // (wptexturize, entity encoding) so "&" became "&" in the review // modal's "current" field and in the text sent to the model. return array( 'title' => $post->post_title, 'meta' => self::read_meta( $post->ID ), 'excerpt_html' => $html, ); } private static function read_meta( $post_id ) { foreach ( array( '_yoast_wpseo_metadesc', 'rank_math_description', '_refrank_meta_description' ) as $k ) { $v = get_post_meta( $post_id, $k, true ); if ( $v ) { return (string) $v; } } return ''; } private static function apply_meta( $post_id, $meta ) { update_post_meta( $post_id, '_refrank_meta_description', $meta ); if ( get_post_meta( $post_id, '_yoast_wpseo_metadesc', true ) || defined( 'WPSEO_VERSION' ) ) { update_post_meta( $post_id, '_yoast_wpseo_metadesc', $meta ); } if ( get_post_meta( $post_id, 'rank_math_description', true ) || class_exists( 'RankMath' ) ) { update_post_meta( $post_id, 'rank_math_description', $meta ); } } }