'GET', 'callback' => self::cb( 'status' ), 'permission_callback' => $auth ) ); register_rest_route( self::NS, '/products', array( 'methods' => 'GET', 'callback' => self::cb( 'products' ), 'permission_callback' => $consent, 'args' => array( 'page' => array( 'default' => 1 ), 'per_page' => array( 'default' => 100 ), ), ) ); register_rest_route( self::NS, '/products/(?P\d+)/fixes', array( 'methods' => 'POST', 'callback' => self::cb( 'fixes' ), 'permission_callback' => $auth ) ); register_rest_route( self::NS, '/changes/(?P[A-Za-z0-9\-]{8,36})/undo', array( 'methods' => 'POST', 'callback' => self::cb( 'undo' ), 'permission_callback' => $auth ) ); register_rest_route( self::NS, '/changes', array( 'methods' => 'GET', 'callback' => self::cb( 'changes' ), 'permission_callback' => $consent ) ); register_rest_route( self::NS, '/settings', array( array( 'methods' => 'GET', 'callback' => self::cb( 'settings_get' ), 'permission_callback' => $auth ), array( 'methods' => 'POST', 'callback' => self::cb( 'settings_post' ), 'permission_callback' => $auth ), ) ); register_rest_route( self::NS, '/bot-log', array( 'methods' => 'GET', 'callback' => self::cb( 'bot_log' ), 'permission_callback' => $consent ) ); register_rest_route( self::NS, '/referrals', array( 'methods' => 'GET', 'callback' => self::cb( 'referrals' ), 'permission_callback' => $consent ) ); register_rest_route( self::NS, '/beacon', array( 'methods' => 'POST', 'callback' => self::cb( 'beacon' ), 'permission_callback' => '__return_true' ) ); // v1 Connect ownership check: public by design (the service calls it); answers 200 only for the pending pair. register_rest_route( self::NS, '/connect/confirm', array( 'methods' => 'POST', 'callback' => self::cb( 'connect_confirm' ), 'permission_callback' => '__return_true' ) ); } /** Wrap a handler so exceptions become JSON 500s instead of fatals. */ private static function cb( string $method ): \Closure { return static function ( \WP_REST_Request $req ) use ( $method ) { try { $res = self::$method( $req ); } catch ( \Throwable $e ) { Util::log( 'REST ' . $method . ': ' . $e->getMessage() ); // Details go to debug.log only; responses never echo internals (paths, SQL, config values). return new \WP_Error( 'flareops_error', 'internal error', array( 'status' => 500 ) ); } return $res instanceof \WP_REST_Response || is_wp_error( $res ) ? $res : rest_ensure_response( $res ); }; } /* ---------- auth ---------- */ private static function bearer( \WP_REST_Request $req ): string { $h = (string) $req->get_header( 'authorization' ); if ( '' === $h ) { $h = Util::server_text( 'REDIRECT_HTTP_AUTHORIZATION', 1024 ); } if ( preg_match( '/^\s*Bearer\s+(\S+)\s*$/i', $h, $m ) ) { return $m[1]; } // Fallback for hosts that strip Authorization. return trim( (string) $req->get_header( 'x_flareops_token' ) ); } /** Bearer = the v1 site secret (Connect) or the PoC token (build constant / legacy option). Both via hash_equals. */ public static function auth( \WP_REST_Request $req ) { $given = self::bearer( $req ); $ok = false; if ( '' !== $given ) { foreach ( array( Connect::secret(), Settings::api_token() ) as $expected ) { if ( '' !== $expected && hash_equals( $expected, $given ) ) { $ok = true; } } } if ( ! $ok ) { return new \WP_Error( 'flareops_unauthorized', 'missing or invalid bearer token', array( 'status' => 401 ) ); } return true; } public static function auth_with_consent( \WP_REST_Request $req ) { $a = self::auth( $req ); if ( true !== $a ) { return $a; } if ( ! Settings::has_consent() ) { return new \WP_Error( 'flareops_no_consent', 'the store admin has not granted data-sharing consent (WooCommerce > flareOps)', array( 'status' => 403 ) ); } return true; } private static function need_woo() { if ( ! Util::woo_active() ) { return new \WP_Error( 'flareops_woocommerce_inactive', 'WooCommerce is not active', array( 'status' => 503 ) ); } return null; } private static function since_date( \WP_REST_Request $req, int $default_days = 30 ): string { $s = (string) $req->get_param( 'since' ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}/', $s ) ) { $ts = strtotime( substr( $s, 0, 10 ) . ' 00:00:00 UTC' ); if ( false !== $ts ) { return gmdate( 'Y-m-d', $ts ); } } return gmdate( 'Y-m-d', time() - $default_days * DAY_IN_SECONDS ); } private static function json_body( \WP_REST_Request $req ): array { $b = $req->get_json_params(); if ( is_array( $b ) ) { return $b; } $raw = json_decode( (string) $req->get_body(), true ); return is_array( $raw ) ? $raw : array(); } /* ---------- handlers ---------- */ private static function status( \WP_REST_Request $req ): array { global $wp_version; $s = Settings::all(); $state = IndexNow::state(); $feat = $s['features']; $feat['feed_scope'] = $s['feed_scope']; return array( 'plugin_version' => FLAREOPS_VERSION, 'wp_version' => (string) $wp_version, 'wc_version' => defined( 'WC_VERSION' ) ? WC_VERSION : null, 'woocommerce_active' => Util::woo_active(), 'php_version' => PHP_VERSION, 'site_url' => trailingslashit( home_url() ), 'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : null, 'cache_plugin' => Cache::primary(), 'cache_plugins' => Cache::detect(), 'cache_bot_bypass' => Cache::bypass_ok(), 'physical_robots_txt' => file_exists( ABSPATH . 'robots.txt' ), 'features' => $feat, 'feed_scope' => $s['feed_scope'], 'treatment_ids' => $s['treatment_ids'], 'consent' => Settings::has_consent(), 'connection' => array( 'connected' => Connect::is_connected(), 'site_id' => Connect::site_id(), 'connected_at' => Connect::info()['connected_at'] ?? null, 'poc_build' => Settings::is_poc_build(), ), 'market' => Market::info(), 'events' => array( 'enabled' => Events::enabled(), 'queued' => count( Events::queue() ), 'last_sent' => Events::state()['last_sent'] ?? null, 'last_status' => Events::state()['last_status'] ?? null, ), 'verification' => array( 'google' => '' !== Settings::google_verification(), 'bing' => '' !== Settings::bing_verification(), 'merchant_google' => '' !== $s['merchant_google_verification'], 'merchant_bing' => '' !== $s['merchant_bing_verification'], ), 'indexnow' => array( 'key_url' => IndexNow::key_url(), 'key_url_fallback' => IndexNow::key_url_fallback(), 'enabled' => IndexNow::enabled(), 'last_ping' => $state['last_ping'] ?? null, 'last_status' => $state['last_status'] ?? null, 'queued' => count( (array) get_option( IndexNow::QUEUE_OPTION, array() ) ), ), 'feed_url' => Feed::url(), 'brand_source' => taxonomy_exists( Products::BRAND_TAX ) ? 'product_brand' : '_flareops_brand', 'db_ok' => Install::tables_ok(), ); } private static function products( \WP_REST_Request $req ) { $e = self::need_woo(); if ( $e ) { return $e; } $page = max( 1, (int) $req->get_param( 'page' ) ); $per_page = min( 100, max( 1, (int) $req->get_param( 'per_page' ) ) ); $vars = (string) $req->get_param( 'variations' ); return Products::page( $page, $per_page, ! in_array( $vars, array( '0', 'false', 'no' ), true ) ); } private static function fixes( \WP_REST_Request $req ) { $e = self::need_woo(); if ( $e ) { return $e; } return Changes::apply( (int) $req['id'], self::json_body( $req ) ); } private static function undo( \WP_REST_Request $req ) { $e = self::need_woo(); if ( $e ) { return $e; } $body = self::json_body( $req ); return Changes::undo( (string) $req['change_id'], (string) ( $body['by'] ?? 'api' ) ); } private static function changes( \WP_REST_Request $req ): array { $since = (string) $req->get_param( 'since' ); $sql = null; if ( '' !== $since ) { $ts = strtotime( $since ); $sql = false === $ts ? null : gmdate( 'Y-m-d H:i:s', $ts ); } return array( 'items' => Changes::list_since( $sql ) ); } /** Settings as JSON. Holds no secrets: the token, site secret and pushed values are never part of it. */ public static function settings_out( $s ) { if ( ! is_array( $s ) ) { return $s; } $s['google_category_map'] = (object) $s['google_category_map']; return $s; } private static function settings_get( \WP_REST_Request $req ): array { return self::settings_out( Settings::all() ); } private static function settings_post( \WP_REST_Request $req ) { $body = self::json_body( $req ); if ( ! $body ) { return new \WP_Error( 'flareops_invalid', 'JSON body required', array( 'status' => 400 ) ); } $allowed = array( 'features', 'treatment_ids', 'shipping_text', 'returns_text', 'returns_days', 'feed_scope', 'merchant_google_verification', 'merchant_bing_verification', 'default_brand', 'feed_shipping', 'google_category_map', 'content_any_product', 'google_verification', 'bing_verification', 'indexnow_key' ); $unknown = array_diff( array_keys( $body ), $allowed ); if ( $unknown ) { return new \WP_Error( 'flareops_invalid', 'unknown settings: ' . implode( ', ', array_map( 'sanitize_key', $unknown ) ), array( 'status' => 400 ) ); } return self::settings_out( Settings::update( $body ) ); } private static function bot_log( \WP_REST_Request $req ): array { return Bots::report( self::since_date( $req ) ); } private static function referrals( \WP_REST_Request $req ): array { return Referrals::report( self::since_date( $req ) ); } private static function connect_confirm( \WP_REST_Request $req ): \WP_REST_Response { $raw = (string) $req->get_body(); $body = json_decode( $raw, true ); return Connect::confirm( is_array( $body ) ? $body : array(), strlen( $raw ) ); } private static function beacon( \WP_REST_Request $req ) { $limited = Referrals::throttle( strlen( (string) $req->get_body() ) ); if ( null !== $limited ) { $res = new \WP_REST_Response( array( 'ok' => false, 'counted' => false, 'reason' => $limited ), 'too_large' === $limited ? 413 : 429 ); $res->header( 'Cache-Control', 'no-store' ); if ( 'too_large' !== $limited ) { $res->header( 'Retry-After', '60' ); } return $res; } $b = self::json_body( $req ); $r = Referrals::record( is_string( $b['r'] ?? null ) ? $b['r'] : '', is_string( $b['u'] ?? null ) ? $b['u'] : '', is_string( $b['p'] ?? null ) ? $b['p'] : '/' ); $res = new \WP_REST_Response( $r, 200 ); $res->header( 'Cache-Control', 'no-store' ); return $res; } }