kb sourceId) and * kasp_sync_errors (bounded log surfaced in the admin app). * * @package kasp */ defined( 'ABSPATH' ) || exit; /** * Typed, sanitizing accessors for every option the plugin owns. */ class Kasp_Settings { const OPTION_CONNECTION = 'kasp_connection'; const OPTION_SETTINGS = 'kasp_settings'; const OPTION_SOURCE_MAP = 'kasp_source_map'; const OPTION_BROKEN = 'kasp_connection_broken'; const OPTION_ERRORS = 'kasp_sync_errors'; const MAX_EXCLUDED_IDS = 500; const MAX_EXCLUDED_TYPES = 100; const MAX_EXCLUDED_PATTERNS = 100; const MAX_PATTERN_LENGTH = 300; const MAX_HIDE_ROLES = 30; const MAX_ERRORS = 20; /** * Default local settings. * * @return array */ public static function defaults() { return array( 'enabled' => true, // WooCommerce storefront buttons (class-kasp-store-placements.php). 'woo_product_button' => true, 'woo_order_help' => true, 'excluded_post_ids' => array(), 'excluded_post_types' => array(), 'excluded_url_patterns' => array(), 'hide_for_logged_in' => false, 'hide_for_roles' => array(), 'auto_sync' => false, ); } /** * Empty connection shape. * * @return array */ public static function empty_connection() { return array( 'token' => '', 'org_id' => '', 'project_id' => '', 'project_name' => '', 'site_key' => '', 'connected_at' => 0, ); } /* --------------------------------------------------------------------- */ /* Connection */ /* --------------------------------------------------------------------- */ /** * Read the stored connection (always the full shape). * * @return array */ public static function get_connection() { $stored = get_option( self::OPTION_CONNECTION, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return self::sanitize_connection( array_merge( self::empty_connection(), $stored ) ); } /** * Persist a connection. * * @param array $connection Raw connection data. * @return array The sanitized connection that was stored. */ public static function update_connection( $connection ) { $clean = self::sanitize_connection( array_merge( self::empty_connection(), (array) $connection ) ); update_option( self::OPTION_CONNECTION, $clean, false ); self::clear_connection_broken(); return $clean; } /** * Forget the connection (local only — revocation is the caller's job). * * @return void */ public static function clear_connection() { delete_option( self::OPTION_CONNECTION ); delete_option( self::OPTION_SOURCE_MAP ); self::clear_connection_broken(); } /** * Sanitize a connection payload. * * @param array $connection Raw connection data. * @return array */ public static function sanitize_connection( $connection ) { return array( 'token' => sanitize_text_field( isset( $connection['token'] ) ? (string) $connection['token'] : '' ), 'org_id' => sanitize_text_field( isset( $connection['org_id'] ) ? (string) $connection['org_id'] : '' ), 'project_id' => sanitize_text_field( isset( $connection['project_id'] ) ? (string) $connection['project_id'] : '' ), 'project_name' => sanitize_text_field( isset( $connection['project_name'] ) ? (string) $connection['project_name'] : '' ), 'site_key' => sanitize_text_field( isset( $connection['site_key'] ) ? (string) $connection['site_key'] : '' ), 'connected_at' => isset( $connection['connected_at'] ) ? absint( $connection['connected_at'] ) : 0, ); } /** * Is the site connected to helpo? * * @return bool */ public static function is_connected() { $connection = self::get_connection(); return '' !== $connection['token'] && '' !== $connection['project_id']; } /** * The bearer token. Server-side use only. * * @return string */ public static function get_token() { $connection = self::get_connection(); return $connection['token']; } /** * Connected project id. * * @return string */ public static function get_project_id() { $connection = self::get_connection(); return $connection['project_id']; } /** * Connected project name. * * @return string */ public static function get_project_name() { $connection = self::get_connection(); return $connection['project_name']; } /** * Public widget site key (safe to expose — it ships in the embed snippet). * * @return string */ public static function get_site_key() { $connection = self::get_connection(); return $connection['site_key']; } /** * Site key for the front-end embed path, read WITHOUT materializing the * bearer token. * * The wp_footer snippet only needs the (public) site key, so the token field * of the connection option is never read into a variable on any anonymous * front-end request. Returns '' when the site is not connected / has no key. * * @return string */ public static function get_public_site_key() { $stored = get_option( self::OPTION_CONNECTION, array() ); if ( ! is_array( $stored ) || empty( $stored['site_key'] ) ) { return ''; } return sanitize_text_field( (string) $stored['site_key'] ); } /** * Connected organization id. * * @return string */ public static function get_org_id() { $connection = self::get_connection(); return $connection['org_id']; } /** * Flag the stored token as rejected (401) so the UI can prompt a reconnect. * * @return void */ public static function mark_connection_broken() { update_option( self::OPTION_BROKEN, time(), false ); } /** * Clear the reconnect prompt. * * @return void */ public static function clear_connection_broken() { delete_option( self::OPTION_BROKEN ); } /** * Has the API rejected our token? * * @return bool */ public static function is_connection_broken() { return (bool) get_option( self::OPTION_BROKEN, false ); } /* --------------------------------------------------------------------- */ /* Local settings */ /* --------------------------------------------------------------------- */ /** * Read local settings, merged over the defaults. * * @return array */ public static function get_settings() { $stored = get_option( self::OPTION_SETTINGS, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return self::sanitize_settings( array_merge( self::defaults(), $stored ) ); } /** * Read a single setting. * * @param string $key Setting key. * @param mixed $default Fallback when the key is unknown. * @return mixed */ public static function get_setting( $key, $default = null ) { $settings = self::get_settings(); return array_key_exists( $key, $settings ) ? $settings[ $key ] : $default; } /** * Merge and persist a partial settings update. * * @param array $input Partial settings. * @return array The sanitized settings that were stored. */ public static function update_settings( $input ) { $merged = array_merge( self::get_settings(), (array) $input ); $clean = self::sanitize_settings( $merged ); update_option( self::OPTION_SETTINGS, $clean, true ); return $clean; } /** * Central sanitization for the settings option. * * @param array $input Raw settings. * @return array */ public static function sanitize_settings( $input ) { $input = (array) $input; $clean = self::defaults(); $clean['enabled'] = ! empty( $input['enabled'] ); $clean['woo_product_button'] = ! empty( $input['woo_product_button'] ); $clean['woo_order_help'] = ! empty( $input['woo_order_help'] ); $clean['hide_for_logged_in'] = ! empty( $input['hide_for_logged_in'] ); $clean['auto_sync'] = ! empty( $input['auto_sync'] ); if ( isset( $input['excluded_post_ids'] ) && is_array( $input['excluded_post_ids'] ) ) { $ids = array(); foreach ( $input['excluded_post_ids'] as $id ) { $id = absint( $id ); if ( $id > 0 ) { $ids[] = $id; } } $clean['excluded_post_ids'] = array_values( array_slice( array_unique( $ids ), 0, self::MAX_EXCLUDED_IDS ) ); } if ( isset( $input['excluded_post_types'] ) && is_array( $input['excluded_post_types'] ) ) { $types = array(); foreach ( $input['excluded_post_types'] as $type ) { $type = sanitize_key( (string) $type ); if ( '' !== $type ) { $types[] = $type; } } $clean['excluded_post_types'] = array_values( array_slice( array_unique( $types ), 0, self::MAX_EXCLUDED_TYPES ) ); } if ( isset( $input['excluded_url_patterns'] ) && is_array( $input['excluded_url_patterns'] ) ) { $patterns = array(); foreach ( $input['excluded_url_patterns'] as $pattern ) { $pattern = trim( sanitize_text_field( (string) $pattern ) ); if ( '' !== $pattern ) { $patterns[] = substr( $pattern, 0, self::MAX_PATTERN_LENGTH ); } } $clean['excluded_url_patterns'] = array_values( array_slice( array_unique( $patterns ), 0, self::MAX_EXCLUDED_PATTERNS ) ); } if ( isset( $input['hide_for_roles'] ) && is_array( $input['hide_for_roles'] ) ) { $roles = array(); foreach ( $input['hide_for_roles'] as $role ) { $role = sanitize_key( (string) $role ); if ( '' !== $role ) { $roles[] = $role; } } $clean['hide_for_roles'] = array_values( array_slice( array_unique( $roles ), 0, self::MAX_HIDE_ROLES ) ); } return $clean; } /* --------------------------------------------------------------------- */ /* postId => kb sourceId map */ /* --------------------------------------------------------------------- */ /** * The whole map. * * @return array */ public static function get_source_map() { $map = get_option( self::OPTION_SOURCE_MAP, array() ); if ( ! is_array( $map ) ) { return array(); } $clean = array(); foreach ( $map as $post_id => $source_id ) { $post_id = absint( $post_id ); $source_id = sanitize_text_field( (string) $source_id ); if ( $post_id > 0 && '' !== $source_id ) { $clean[ $post_id ] = $source_id; } } return $clean; } /** * Look up one post. * * @param int $post_id Post id. * @return string Source id, or '' when unmapped. */ public static function get_source_id( $post_id ) { $map = self::get_source_map(); $post_id = absint( $post_id ); return isset( $map[ $post_id ] ) ? $map[ $post_id ] : ''; } /** * Map a post to a kb source. * * @param int $post_id Post id. * @param string $source_id kb source id. * @return void */ public static function set_source_id( $post_id, $source_id ) { $map = self::get_source_map(); $post_id = absint( $post_id ); if ( $post_id < 1 ) { return; } $map[ $post_id ] = sanitize_text_field( (string) $source_id ); update_option( self::OPTION_SOURCE_MAP, $map, false ); } /** * Drop a post from the map. * * @param int $post_id Post id. * @return void */ public static function forget_source_id( $post_id ) { $map = self::get_source_map(); $post_id = absint( $post_id ); if ( isset( $map[ $post_id ] ) ) { unset( $map[ $post_id ] ); update_option( self::OPTION_SOURCE_MAP, $map, false ); } } /** * Replace the whole map (used by lazy reconciliation). * * @param array $map postId => sourceId. * @return void */ public static function set_source_map( $map ) { update_option( self::OPTION_SOURCE_MAP, (array) $map, false ); } /* --------------------------------------------------------------------- */ /* Bounded sync error log */ /* --------------------------------------------------------------------- */ /** * Append a sync error. Oldest entries fall off the end. * * @param string $message Human readable message. * @param array $context Optional small context (post id, source id). * @return void */ public static function log_sync_error( $message, $context = array() ) { $errors = self::get_sync_errors(); array_unshift( $errors, array( 'message' => sanitize_text_field( (string) $message ), 'post_id' => isset( $context['post_id'] ) ? absint( $context['post_id'] ) : 0, 'at' => time(), ) ); update_option( self::OPTION_ERRORS, array_slice( $errors, 0, self::MAX_ERRORS ), false ); } /** * Recent sync errors, newest first. * * @return array */ public static function get_sync_errors() { $errors = get_option( self::OPTION_ERRORS, array() ); return is_array( $errors ) ? $errors : array(); } /** * Empty the error log. * * @return void */ public static function clear_sync_errors() { delete_option( self::OPTION_ERRORS ); } }