'GET', 'callback' => array( $this, 'list_experiments' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => 'POST', 'callback' => array( $this, 'create_experiment' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); register_rest_route( $ns, '/experiments/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_experiment' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_experiment' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete_experiment' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); foreach ( array( 'start', 'pause', 'resume', 'complete', 'cancel' ) as $action ) { register_rest_route( $ns, '/experiments/(?P\d+)/' . $action, array( 'methods' => 'POST', 'callback' => function ( \WP_REST_Request $request ) use ( $action ) { return $this->transition( $request, $action ); }, 'permission_callback' => array( $this, 'can_manage' ), ) ); } register_rest_route( $ns, '/experiments/(?P\d+)/apply-winner', array( 'methods' => 'POST', 'callback' => array( $this, 'apply_winner' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/experiments/(?P\d+)/rollback', array( 'methods' => 'POST', 'callback' => array( $this, 'rollback' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/experiments/(?P\d+)/statistics', array( 'methods' => 'GET', 'callback' => array( $this, 'statistics' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/experiments/(?P\d+)/history', array( 'methods' => 'GET', 'callback' => array( $this, 'history' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/experiments/(?P\d+)/export', array( 'methods' => 'GET', 'callback' => array( $this, 'export_config' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/import', array( 'methods' => 'POST', 'callback' => array( $this, 'import_config' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/posts', array( 'methods' => 'GET', 'callback' => array( $this, 'search_posts' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/posts/(?P\d+)/duplicate', array( 'methods' => 'POST', 'callback' => array( $this, 'duplicate_post' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/runtime/themes', array( 'methods' => 'GET', 'callback' => array( $this, 'themes' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/runtime/plugins', array( 'methods' => 'GET', 'callback' => array( $this, 'plugins' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/runtime/check', array( 'methods' => 'POST', 'callback' => array( $this, 'check_environment' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/goals', array( 'methods' => 'GET', 'callback' => array( $this, 'goals' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( $ns, '/settings', array( array( 'methods' => 'GET', 'callback' => array( $this, 'get_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => 'PUT', 'callback' => array( $this, 'update_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); register_rest_route( $ns, '/track', array( 'methods' => 'POST', 'callback' => array( $this, 'track' ), 'permission_callback' => '__return_true', ) ); } /** * Administrators and roles granted manage_wpab. * * @return bool */ public function can_manage(): bool { return current_user_can( Settings::capability() ) || current_user_can( 'manage_options' ); } /** * List experiments. * * @return \WP_REST_Response */ public function list_experiments(): \WP_REST_Response { global $wpdb; $stats = wp_cache_get( 'experiment_visitor_totals', 'wpab' ); if ( ! is_array( $stats ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Custom tables have no WordPress CRUD API. $stats = $wpdb->get_results( $wpdb->prepare( 'SELECT experiment_id, SUM(visitors) AS visitors FROM %i GROUP BY experiment_id', \WPAB\Database\Tables::name( 'daily_stats' ) ), ARRAY_A ); $stats = is_array( $stats ) ? $stats : array(); wp_cache_set( 'experiment_visitor_totals', $stats, 'wpab' ); } $counts = array(); if ( is_array( $stats ) ) { foreach ( $stats as $row ) { $counts[ (int) $row['experiment_id'] ] = (int) $row['visitors']; } } $items = array(); foreach ( $this->repository->list_recent( 100, 0 ) as $experiment ) { $full = $this->experiments->get( (int) $experiment['id'] ); if ( null === $full ) { continue; } $full['visitors'] = $counts[ (int) $experiment['id'] ] ?? 0; $items[] = $full; } return new \WP_REST_Response( $items ); } /** * Create. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function create_experiment( \WP_REST_Request $request ) { return $this->guard( function () use ( $request ) { $start = (bool) $request->get_param( 'start' ); return $this->experiments->create( $request->get_json_params(), get_current_user_id(), $start ); } ); } /** * Read. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function get_experiment( \WP_REST_Request $request ) { $experiment = $this->experiments->get( (int) $request['id'] ); if ( null === $experiment ) { return new \WP_Error( 'wpab_not_found', __( 'Experiment not found.', 'jm-split-tester' ), array( 'status' => 404 ) ); } return new \WP_REST_Response( $experiment ); } /** * Update a draft. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function update_experiment( \WP_REST_Request $request ) { return $this->guard( fn () => $this->experiments->update( (int) $request['id'], $request->get_json_params() ) ); } /** * Delete an experiment and its statistics. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function delete_experiment( \WP_REST_Request $request ) { return $this->guard( function () use ( $request ) { $this->experiments->delete( (int) $request['id'] ); return array( 'deleted' => true ); } ); } /** * Status transition. * * @param \WP_REST_Request $request Request. * @param string $action Action. * @return \WP_REST_Response|\WP_Error */ public function transition( \WP_REST_Request $request, string $action ) { return $this->guard( fn () => $this->experiments->transition( (int) $request['id'], $action ) ); } /** * Apply winner. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function apply_winner( \WP_REST_Request $request ) { return $this->guard( function () use ( $request ) { $body = $request->get_json_params(); return $this->applier->apply( (int) $request['id'], (int) ( $body['variant_id'] ?? 0 ), ! empty( $body['confirm'] ) ); } ); } /** * Rollback. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rollback( \WP_REST_Request $request ) { return $this->guard( fn () => $this->applier->rollback( (int) $request['id'] ) ); } /** * Statistics. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function statistics( \WP_REST_Request $request ): \WP_REST_Response { $range = (string) $request->get_param( 'range' ); if ( ! in_array( $range, array( '24h', '7d', '30d', 'all' ), true ) ) { $range = 'all'; } return new \WP_REST_Response( $this->stats->for_experiment( (int) $request['id'], $range ) ); } /** * History. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function history( \WP_REST_Request $request ): \WP_REST_Response { return new \WP_REST_Response( $this->history->for_experiment( (int) $request['id'] ) ); } /** * JSON configuration export. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function export_config( \WP_REST_Request $request ) { $data = $this->transfer->export( (int) $request['id'] ); if ( array() === $data ) { return new \WP_Error( 'wpab_not_found', __( 'Experiment not found.', 'jm-split-tester' ), array( 'status' => 404 ) ); } return new \WP_REST_Response( $data ); } /** * Import configuration. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function import_config( \WP_REST_Request $request ) { return $this->guard( fn () => $this->transfer->import( $request->get_json_params(), get_current_user_id() ) ); } /** * Search viewable content. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function search_posts( \WP_REST_Request $request ): \WP_REST_Response { $type = sanitize_key( (string) $request->get_param( 'type' ) ); $args = array( 'post_type' => '' !== $type ? $type : array( 'post', 'page' ), 'post_status' => array( 'publish', 'private' ), 's' => sanitize_text_field( (string) $request->get_param( 'search' ) ), 'posts_per_page' => 20, ); if ( 'any' === $type ) { $args['post_type'] = get_post_types( array( 'public' => true ) ); } $query = new \WP_Query( $args ); $posts = array(); foreach ( $query->posts as $post ) { if ( ! $post instanceof \WP_Post || ! current_user_can( 'edit_post', $post->ID ) ) { continue; } if ( absint( get_post_meta( $post->ID, \WPAB\Experiments\Variant_Copy::META, true ) ) > 0 ) { continue; } $posts[] = array( 'id' => (int) $post->ID, 'title' => get_the_title( $post ), 'type' => (string) $post->post_type, 'url' => get_permalink( $post ), ); } return new \WP_REST_Response( $posts ); } /** * Create a draft copy of a post for variant editing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function duplicate_post( \WP_REST_Request $request ) { return $this->guard( function () use ( $request ) { $replace = absint( $request->get_param( 'replace' ) ); return ( new \WPAB\Experiments\Post_Duplicator() )->duplicate( (int) $request['id'], $replace ); } ); } /** * Installed themes. * * @return \WP_REST_Response */ public function themes(): \WP_REST_Response { return new \WP_REST_Response( $this->themes->catalog() ); } /** * Active plugins. Must-use plugins are listed separately and cannot be disabled. * * @return \WP_REST_Response */ public function plugins(): \WP_REST_Response { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all = get_plugins(); $active = (array) get_option( 'active_plugins', array() ); $network = is_multisite() ? array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) : array(); $items = array(); foreach ( $all as $file => $data ) { $items[] = array( 'file' => (string) $file, 'name' => (string) ( $data['Name'] ?? $file ), 'active' => in_array( $file, $active, true ) || in_array( $file, $network, true ), 'network' => in_array( $file, $network, true ), 'mu' => false, 'requires' => (string) ( $data['RequiresPlugins'] ?? '' ), ); } return new \WP_REST_Response( $items ); } /** * Compatibility check for a proposed environment. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function check_environment( \WP_REST_Request $request ): \WP_REST_Response { $body = $request->get_json_params(); $disabled = isset( $body['disabled_plugins'] ) && is_array( $body['disabled_plugins'] ) ? array_map( 'strval', $body['disabled_plugins'] ) : array(); $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } $environment = array( 'theme' => sanitize_key( (string) ( $body['theme'] ?? '' ) ), 'disabled_plugins' => $disabled, 'active_plugins' => $active, ); return new \WP_REST_Response( array( 'notes' => $this->compatibility->check_experiment_environment( $environment ), 'active' => ( new Plugin_Resolver() )->filter_list( array_map( 'strval', $active ), $disabled ), ) ); } /** * Goals across experiments. * * @return \WP_REST_Response */ public function goals(): \WP_REST_Response { return new \WP_REST_Response( ( new \WPAB\Experiments\Goal_Repository() )->all() ); } /** * Settings. * * @return \WP_REST_Response */ public function get_settings(): \WP_REST_Response { return new \WP_REST_Response( Settings::all() ); } /** * Update settings. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function update_settings( \WP_REST_Request $request ): \WP_REST_Response { $clean = Settings::sanitize( $request->get_json_params() ); update_option( Settings::OPTION, $clean, false ); return new \WP_REST_Response( $clean ); } /** * Public tracker. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function track( \WP_REST_Request $request ) { try { return new \WP_REST_Response( $this->tracking->record( $request->get_json_params() ) ); } catch ( \InvalidArgumentException $error ) { $status = str_contains( $error->getMessage(), 'Too many' ) ? 429 : 400; return new \WP_Error( 'wpab_track', $error->getMessage(), array( 'status' => $status ) ); } } /** * Convert validation failures into REST errors. * * @param callable $callback Callback. * @return \WP_REST_Response|\WP_Error */ private function guard( callable $callback ) { try { return new \WP_REST_Response( $callback() ); } catch ( Validation_Exception $error ) { return new \WP_Error( 'wpab_' . $error->error_code(), wp_specialchars_decode( $error->getMessage(), ENT_QUOTES ), array( 'status' => 400 ) ); } catch ( \Throwable $error ) { \WPAB\Support\Logger::exception( $error ); return new \WP_Error( 'wpab_error', __( 'The experiment could not be saved.', 'jm-split-tester' ), array( 'status' => 500 ) ); } } }