helpo knowledge base. */ class Kasp_Sync { const CRON_HOOK = 'kasp_sync_post'; const CRON_DELAY = 30; const MAP_RECONCILE_TRANSIENT = 'kasp_source_map_synced'; const MAP_RECONCILE_TTL = 300; const BATCH_SIZE = 200; const MAX_INVENTORY = 500; /** * API client. * * @var Kasp_Api_Client */ private $api; /** * Constructor. * * @param Kasp_Api_Client $api API client. */ public function __construct( Kasp_Api_Client $api ) { $this->api = $api; } /** * Register hooks. The post hooks only attach when auto sync is on. * * @return void */ public function register() { add_action( self::CRON_HOOK, array( $this, 'run_scheduled' ), 10, 1 ); if ( ! Kasp_Settings::is_connected() ) { return; } // Removal is NOT the paid feature and is never optional: a page the owner // unpublishes, password-protects or trashes must leave the public knowledge // base whether or not auto-sync is on — otherwise the agent keeps quoting // text the site has deliberately taken down. These three hooks therefore // attach on every connected site; run_scheduled() applies the auto_sync // gate only to the create/re-sync half. add_action( 'transition_post_status', array( $this, 'on_transition' ), 20, 3 ); add_action( 'trashed_post', array( $this, 'on_removed' ), 20, 1 ); add_action( 'before_delete_post', array( $this, 'on_removed' ), 20, 1 ); if ( ! Kasp_Settings::get_setting( 'auto_sync', false ) ) { return; } add_action( 'save_post', array( $this, 'on_save_post' ), 20, 2 ); } /** * Post types the plugin pushes into the knowledge base. * * @return string[] */ public static function post_types() { /** * Filters the post types synced to the helpo knowledge base. * * @since 0.1.0 * @param string[] $types Post type slugs. */ $types = apply_filters( 'kasp_sync_post_types', array( 'page', 'post' ) ); return array_values( array_filter( array_map( 'sanitize_key', (array) $types ) ) ); } /* --------------------------------------------------------------------- */ /* Local inventory */ /* --------------------------------------------------------------------- */ /** * Published content with permalinks — what the wizard offers to import. * * @param int $limit Maximum rows to return. * @return array { * @type int $total Number of published items across synced types. * @type array $items Rows of id/title/url/type, plus whether to tick it by default. * @type array $types Per-type counts. * } */ public function inventory( $limit = self::MAX_INVENTORY ) { $limit = min( max( (int) $limit, 1 ), self::MAX_INVENTORY ); $types = self::post_types(); $map = Kasp_Settings::get_source_map(); $query = new WP_Query( array( 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'has_password' => false, 'ignore_sticky_posts' => true, 'no_found_rows' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $store_pages = self::store_utility_page_ids(); $items = array(); foreach ( $query->posts as $post ) { $items[] = array( 'id' => (int) $post->ID, 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'type' => $post->post_type, 'modified' => get_post_modified_time( 'c', true, $post ), 'sourceId' => isset( $map[ $post->ID ] ) ? $map[ $post->ID ] : null, // Listed, but not ticked by default: nothing to answer from. 'suggested' => ! in_array( (int) $post->ID, $store_pages, true ) && ! Kasp_Help_Center::has_help_center( $post ), ); } $per_type = array(); foreach ( $types as $type ) { $counts = wp_count_posts( $type ); $per_type[ $type ] = isset( $counts->publish ) ? (int) $counts->publish : 0; } return array( 'total' => (int) $query->found_posts, 'returned' => count( $items ), 'items' => $items, 'types' => $per_type, 'postTypes' => $types, 'siteHost' => self::site_host(), ); } /** * WooCommerce's cart, checkout and account pages. A store needs them, but * they render the shopper's own session ("Your cart is currently empty"), * so importing them only spends knowledge-base pages on noise. * * @return int[] */ private static function store_utility_page_ids() { if ( ! function_exists( 'wc_get_page_id' ) ) { return array(); } $ids = array(); foreach ( array( 'cart', 'checkout', 'myaccount' ) as $page ) { $id = (int) wc_get_page_id( $page ); if ( $id > 0 ) { $ids[] = $id; } } return $ids; } /** * Every published permalink of the synced types. * * @return string[] */ public function all_permalinks() { $ids = get_posts( array( 'post_type' => self::post_types(), 'post_status' => 'publish', 'posts_per_page' => self::MAX_INVENTORY, 'fields' => 'ids', 'has_password' => false, 'ignore_sticky_posts' => true, 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $urls = array(); foreach ( $ids as $id ) { $url = get_permalink( $id ); if ( $url ) { $urls[] = $url; } } return array_values( array_unique( $urls ) ); } /* --------------------------------------------------------------------- */ /* Bulk import */ /* --------------------------------------------------------------------- */ /** * Push a list of URLs into the knowledge base in batches of BATCH_SIZE. * * @param string[] $urls Absolute URLs. * @return array|WP_Error { imported: int, sources: array } */ public function import_urls( $urls ) { $project_id = Kasp_Settings::get_project_id(); if ( '' === $project_id ) { return new WP_Error( 'kasp_not_connected', __( 'This site is not connected to helpo yet.', 'helpo' ), array( 'status' => 409 ) ); } $clean = array(); foreach ( (array) $urls as $url ) { // Only http(s), and only this site's own host. The whole point is // syncing THIS site's content, so an off-host URL is never legitimate — // and the constraint is what stops a request-body URL such as // http://169.254.169.254/… (which survives esc_url_raw) from being // forwarded upstream as an SSRF probe. $url = esc_url_raw( trim( (string) $url ), array( 'http', 'https' ) ); if ( '' !== $url && self::is_own_url( $url ) ) { $clean[] = $url; } } $clean = array_values( array_unique( $clean ) ); if ( empty( $clean ) ) { return new WP_Error( 'kasp_no_urls', __( 'No valid URLs were supplied.', 'helpo' ), array( 'status' => 400 ) ); } $imported = 0; $created = array(); foreach ( array_chunk( $clean, self::BATCH_SIZE ) as $batch ) { $result = $this->api->post( '/api/kb/sources', array( 'type' => 'url', 'projectId' => $project_id, 'website' => self::site_host(), 'urls' => array_values( $batch ), ) ); if ( is_wp_error( $result ) ) { // Partial success is still useful: report what landed alongside // the failure (a plan cap typically bites mid-way). $result->add_data( array_merge( (array) $result->get_error_data(), array( 'imported' => $imported ) ) ); return $result; } foreach ( $result as $source ) { if ( ! is_array( $source ) || empty( $source['id'] ) ) { continue; } $imported++; $created[] = $source; $this->remember_source( $source ); } } delete_transient( self::MAP_RECONCILE_TRANSIENT ); return array( 'imported' => $imported, 'sources' => $created, ); } /* --------------------------------------------------------------------- */ /* Auto sync */ /* --------------------------------------------------------------------- */ /** * save_post handler — schedule only. * * @param int $post_id Post id. * @param WP_Post $post Post object. * @return void */ public function on_save_post( $post_id, $post ) { if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) { return; } if ( 'auto-draft' === $post->post_status ) { return; } $this->schedule( $post_id ); } /** * transition_post_status handler — catches unpublishing too. * * @param string $new_status New status. * @param string $old_status Old status. * @param WP_Post $post Post object. * @return void */ public function on_transition( $new_status, $old_status, $post ) { if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, self::post_types(), true ) ) { return; } if ( 'publish' !== $new_status && 'publish' !== $old_status ) { return; } // Without auto-sync only the removal direction is ours: a post leaving // `publish` (to private, draft, pending…) with a source on record. Every // other transition would schedule a job run_scheduled() then refuses. if ( ! Kasp_Settings::get_setting( 'auto_sync', false ) ) { if ( 'publish' !== $old_status || 'publish' === $new_status ) { return; } if ( '' === Kasp_Settings::get_source_id( $post->ID ) ) { return; } } $this->schedule( $post->ID ); } /** * trashed_post / before_delete_post handler. * * @param int $post_id Post id. * @return void */ public function on_removed( $post_id ) { $post_id = absint( $post_id ); if ( $post_id < 1 ) { return; } // Deletion is driven off the postId => sourceId map, so the permalink no // longer has to resolve by the time the job runs. if ( '' === Kasp_Settings::get_source_id( $post_id ) ) { return; } $this->schedule( $post_id ); } /** * Queue one job per post, at most. * * @param int $post_id Post id. * @return void */ private function schedule( $post_id ) { $post_id = absint( $post_id ); if ( $post_id < 1 ) { return; } $args = array( $post_id ); if ( wp_next_scheduled( self::CRON_HOOK, $args ) ) { return; } wp_schedule_single_event( time() + self::CRON_DELAY, self::CRON_HOOK, $args ); } /** * Cron callback: reconcile one post with the knowledge base. * * @param int $post_id Post id. * @return void */ public function run_scheduled( $post_id ) { $post_id = absint( $post_id ); if ( $post_id < 1 || ! Kasp_Settings::is_connected() ) { return; } $post = get_post( $post_id ); $source_id = Kasp_Settings::get_source_id( $post_id ); $wanted = $post instanceof WP_Post && 'publish' === $post->post_status && '' === $post->post_password && in_array( $post->post_type, self::post_types(), true ); if ( ! $wanted ) { if ( '' !== $source_id ) { $result = $this->api->delete( '/api/kb/sources/' . rawurlencode( $source_id ) ); if ( is_wp_error( $result ) && 404 !== (int) $this->status_of( $result ) ) { $this->log_failure( $result, $post_id ); return; } Kasp_Settings::forget_source_id( $post_id ); } return; } // Everything below creates or re-syncs a source, which IS the plan-gated // auto-sync feature; removal above ran regardless of the toggle. if ( ! Kasp_Settings::get_setting( 'auto_sync', false ) ) { return; } $permalink = get_permalink( $post ); if ( ! $permalink ) { return; } if ( '' === $source_id ) { $source_id = $this->find_source_by_location( $permalink ); if ( '' !== $source_id ) { Kasp_Settings::set_source_id( $post_id, $source_id ); } } if ( '' !== $source_id ) { $result = $this->api->post( '/api/kb/sources/' . rawurlencode( $source_id ) . '/resync' ); if ( ! is_wp_error( $result ) ) { return; } $status = (int) $this->status_of( $result ); if ( 402 === $status ) { // Refetch cooldown or plan cap — not an error worth shouting // about on every save, but the user should be able to see it. $this->log_failure( $result, $post_id ); return; } if ( 404 !== $status ) { $this->log_failure( $result, $post_id ); return; } // The source vanished upstream: fall through and recreate it. Kasp_Settings::forget_source_id( $post_id ); } $created = $this->import_urls( array( $permalink ) ); if ( is_wp_error( $created ) ) { $this->log_failure( $created, $post_id ); } } /* --------------------------------------------------------------------- */ /* Helpers */ /* --------------------------------------------------------------------- */ /** * Remote source list for the connected project. * * @return array|WP_Error */ public function remote_sources() { $project_id = Kasp_Settings::get_project_id(); if ( '' === $project_id ) { return new WP_Error( 'kasp_not_connected', __( 'This site is not connected to helpo yet.', 'helpo' ), array( 'status' => 409 ) ); } return $this->api->get( '/api/kb/sources', array( 'projectId' => $project_id ) ); } /** * Lazily rebuild the postId => sourceId map from the remote source list and * return the id matching one permalink. * * @param string $permalink Permalink to look for. * @return string Source id, or '' when there is no match. */ private function find_source_by_location( $permalink ) { if ( get_transient( self::MAP_RECONCILE_TRANSIENT ) ) { return Kasp_Settings::get_source_id( url_to_postid( $permalink ) ); } $sources = $this->remote_sources(); set_transient( self::MAP_RECONCILE_TRANSIENT, time(), self::MAP_RECONCILE_TTL ); if ( is_wp_error( $sources ) ) { return ''; } $map = Kasp_Settings::get_source_map(); $found = ''; foreach ( $sources as $source ) { if ( ! is_array( $source ) || empty( $source['id'] ) || empty( $source['location'] ) ) { continue; } $location = (string) $source['location']; $post_id = url_to_postid( $location ); if ( $post_id > 0 ) { $map[ $post_id ] = (string) $source['id']; } if ( untrailingslashit( $location ) === untrailingslashit( $permalink ) ) { $found = (string) $source['id']; } } Kasp_Settings::set_source_map( $map ); return $found; } /** * Record a created source against its post, when the URL resolves locally. * * @param array $source Source row from the API. * @return void */ private function remember_source( $source ) { if ( empty( $source['location'] ) ) { return; } $post_id = url_to_postid( (string) $source['location'] ); if ( $post_id > 0 ) { Kasp_Settings::set_source_id( $post_id, (string) $source['id'] ); } } /** * Host of this site, used as the `website` grouping for url sources. * * @return string */ public static function site_host() { $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); return $host ? (string) $host : (string) home_url( '/' ); } /** * Is this an absolute http(s) URL on this site's own host? * * Used as the allowlist for every URL the plugin forwards upstream, so a * user- or content-derived URL cannot point the importer at an internal or * third-party host (SSRF). * * @param string $url URL to test. * @return bool */ private static function is_own_url( $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return false; } $scheme = strtolower( (string) $parts['scheme'] ); if ( 'http' !== $scheme && 'https' !== $scheme ) { return false; } return strtolower( (string) $parts['host'] ) === strtolower( self::site_host() ); } /** * HTTP status carried by a WP_Error from the API client. * * @param WP_Error $error Error. * @return int */ private function status_of( $error ) { $data = $error->get_error_data(); return isset( $data['status'] ) ? (int) $data['status'] : 0; } /** * Append to the bounded sync error log. Never throws. * * @param WP_Error $error Error. * @param int $post_id Post id the job was for. * @return void */ private function log_failure( $error, $post_id ) { $title = get_the_title( $post_id ); Kasp_Settings::log_sync_error( sprintf( /* translators: 1: post title, 2: error message. */ __( 'Sync failed for "%1$s": %2$s', 'helpo' ), '' !== $title ? $title : (string) $post_id, $error->get_error_message() ), array( 'post_id' => $post_id ) ); } }