sessions = $sessions; $this->runner = $runner; $this->browse = $browse; $this->loopback = $loopback; } /** * Routes. */ public function register_routes(): void { $p = array( $this, 'permission' ); $this->route( '/sessions', array( array( 'methods' => 'GET', 'callback' => array( $this, 'index' ), 'permission_callback' => $p, ), array( 'methods' => 'POST', 'callback' => array( $this, 'create' ), 'permission_callback' => $p, 'args' => array( 'urls' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => array( $this, 'sanitize_urls' ), ), 'passes' => array( 'type' => 'integer', 'minimum' => 1, 'maximum' => 10, 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'absint', ), 'exclude' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => array( $this, 'validate_plugin_file' ), ), 'label' => array( 'type' => 'string', 'validate_callback' => 'rest_validate_request_arg', 'sanitize_callback' => 'sanitize_text_field', ), ), ), ) ); $this->route( '/sessions/(?P\d+)', array( array( 'methods' => 'GET', 'callback' => array( $this, 'show' ), 'permission_callback' => $p, 'args' => array( 'id' => self::id_arg() ), ), array( 'methods' => 'DELETE', 'callback' => array( $this, 'delete' ), 'permission_callback' => $p, 'args' => array( 'id' => self::id_arg() ), ), ) ); $this->route( '/sessions/(?P\d+)/step', array( 'methods' => 'POST', 'callback' => array( $this, 'step' ), 'permission_callback' => $p, 'args' => array( 'id' => self::id_arg() ), ) ); $this->route( '/sessions/(?P\d+)/cancel', array( 'methods' => 'POST', 'callback' => array( $this, 'cancel' ), 'permission_callback' => $p, 'args' => array( 'id' => self::id_arg() ), ) ); $this->route( '/sessions/(?P\d+)/try-without', array( 'methods' => 'POST', 'callback' => array( $this, 'try_without' ), 'permission_callback' => $p, 'args' => array( 'id' => self::id_arg(), 'plugin' => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => array( $this, 'validate_plugin_file' ), ), ), ) ); $this->route( '/sessions/(?P\d+)/compare/(?P\d+)', array( 'methods' => 'GET', 'callback' => array( $this, 'compare' ), 'permission_callback' => $p, 'args' => array( 'id' => self::id_arg(), 'other' => self::id_arg(), ), ) ); $this->route( '/browse-mode/start', array( 'methods' => 'POST', 'callback' => array( $this, 'browse_start' ), 'permission_callback' => $p, 'args' => array( 'exclude' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => array( $this, 'validate_plugin_file' ), ), ), ) ); $this->route( '/browse-mode/stop', array( 'methods' => 'POST', 'callback' => array( $this, 'browse_stop' ), 'permission_callback' => $p, ) ); $this->route( '/status', array( 'methods' => 'GET', 'callback' => array( $this, 'status' ), 'permission_callback' => $p, ) ); } /** * URL list sanitiser (host rule applied later by UrlSet::sanitize()). * * @param mixed $value Raw value. * @return string[] */ public function sanitize_urls( $value ): array { return array_values( array_filter( array_map( 'esc_url_raw', array_map( 'strval', (array) $value ) ) ) ); } /** * Plugin file argument: '' or an installed plugin file (no paths, never this plugin). * * @param mixed $value Raw value. * @return true|\WP_Error */ public function validate_plugin_file( $value ) { if ( ! is_string( $value ) ) { return new \WP_Error( 'rest_invalid_param', __( 'Pick an installed plugin to exclude.', 'bloatwright' ), array( 'status' => 400 ) ); } return SessionRunner::validate_exclude( sanitize_text_field( $value ) ); } /** * Environment status: loader, loopback, defaults, plugins. Read-only: nothing is written here. */ public function status(): \WP_REST_Response { $plugins = array(); if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } foreach ( get_plugins() as $file => $data ) { if ( is_plugin_active( $file ) && BLOATWRIGHT_BASENAME !== $file ) { $plugins[] = array( 'file' => $file, 'name' => (string) $data['Name'], 'slug' => '.' !== dirname( $file ) ? dirname( $file ) : basename( $file, '.php' ), ); } } $browse = $this->browse->active(); return new \WP_REST_Response( LoaderInstaller::status() + array( 'loopback' => $this->loopback->detect(), 'urls' => UrlSet::defaults(), 'passes' => (int) Settings::get( 'passes', 3 ), 'mode' => (string) Settings::get( 'mode', 'auto' ), 'plugins' => $plugins, 'browse' => $browse, 'browse_expires' => $browse ? $this->browse->expires() : 0, 'browse_expires_in' => $browse ? max( 0, $this->browse->expires() - time() ) : 0, // Seconds left, free of client clock skew. 'running' => $this->running_id(), 'object_cache' => (bool) wp_using_ext_object_cache(), ) ); } /** * Write the must-use loader when it is missing or stale (first run, or after an update / move). * * @return true|\WP_Error */ private function ensure_loader() { $r = LoaderInstaller::ensure(); if ( is_wp_error( $r ) ) { $r->add_data( array( 'status' => 500 ) ); } return $r; } /** * Currently running auto session id. */ private function running_id(): int { return $this->sessions->running_auto(); } /** * Give a runner error an HTTP status: its own (409 while a recording is in progress), else 400. * * @param \WP_Error $error Error. */ private static function with_status( \WP_Error $error ): \WP_Error { $data = $error->get_error_data(); if ( ! is_array( $data ) || empty( $data['status'] ) ) { $error->add_data( array( 'status' => 400 ) ); } return $error; } /** * List. */ public function index(): \WP_REST_Response { $out = array(); foreach ( $this->sessions->all( (int) Settings::get( 'history', 20 ), true ) as $s ) { $out[] = $this->brief( $s ); } return new \WP_REST_Response( $out ); } /** * Brief row for lists (summary reduced to totals + top 5). * * @param array $s Session. */ private function brief( array $s ): array { $summary = (array) ( $s['summary'] ?? array() ); $top = array(); foreach ( array_slice( (array) ( $summary['owners'] ?? array() ), 0, 5 ) as $o ) { $top[] = array( 'key' => $o['key'], 'name' => $o['name'], 'weight' => $o['weight'], 'php_ms' => $o['php_ms'], ); } unset( $s['summary'] ); $s['totals'] = (array) ( $summary['totals'] ?? array() ); $s['top'] = $top; $s['count'] = count( (array) ( $summary['owners'] ?? array() ) ); return $s; } /** * Create + start. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function create( \WP_REST_Request $request ) { $loader = $this->ensure_loader(); if ( is_wp_error( $loader ) ) { return $loader; } if ( $this->running_id() ) { return new \WP_Error( 'bloatwright_running', __( 'A session is already running.', 'bloatwright' ), array( 'status' => 409 ) ); } $urls = (array) $request->get_param( 'urls' ); if ( ! $urls ) { $urls = array_column( UrlSet::defaults(), 'url' ); } $passes = (int) ( $request->get_param( 'passes' ) ?: Settings::get( 'passes', 3 ) ); $exclude = sanitize_text_field( (string) $request->get_param( 'exclude' ) ); $id = $this->runner->start( array_map( 'strval', $urls ), $passes, $exclude, sanitize_text_field( (string) $request->get_param( 'label' ) ) ); if ( is_wp_error( $id ) ) { return self::with_status( $id ); } return new \WP_REST_Response( $this->sessions->find( $id ), 201 ); } /** * Show (full summary + previous session id for compare). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function show( \WP_REST_Request $request ) { $s = $this->sessions->find( (int) $request['id'] ); if ( ! $s ) { return new \WP_Error( 'bloatwright_not_found', __( 'Session not found.', 'bloatwright' ), array( 'status' => 404 ) ); } $prev = $this->sessions->previous_done( (int) $s['id'] ); $s['previous'] = $prev ? (int) $prev['id'] : 0; return new \WP_REST_Response( $s ); } /** * Delete. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function delete( \WP_REST_Request $request ) { if ( ! $this->sessions->delete( (int) $request['id'] ) ) { return new \WP_Error( 'bloatwright_not_found', __( 'Session not found.', 'bloatwright' ), array( 'status' => 404 ) ); } return new \WP_REST_Response( array( 'deleted' => true ) ); } /** * Run one step. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function step( \WP_REST_Request $request ) { $s = $this->runner->step( (int) $request['id'] ); if ( is_wp_error( $s ) ) { return $s; } unset( $s['summary'] ); return new \WP_REST_Response( $s ); } /** * Cancel. * * @param \WP_REST_Request $request Request. */ public function cancel( \WP_REST_Request $request ): \WP_REST_Response { return new \WP_REST_Response( array( 'cancelled' => $this->runner->cancel( (int) $request['id'] ) ) ); } /** * Try without: a new session with the same URLs minus one plugin. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function try_without( \WP_REST_Request $request ) { $s = $this->sessions->find( (int) $request['id'] ); if ( ! $s ) { return new \WP_Error( 'bloatwright_not_found', __( 'Session not found.', 'bloatwright' ), array( 'status' => 404 ) ); } $plugin = sanitize_text_field( (string) $request->get_param( 'plugin' ) ); if ( ! function_exists( 'is_plugin_active' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( '' === $plugin || ! is_plugin_active( $plugin ) || BLOATWRIGHT_BASENAME === $plugin ) { return new \WP_Error( 'bloatwright_invalid', __( 'Pick an active plugin.', 'bloatwright' ), array( 'status' => 400 ) ); } if ( $this->running_id() ) { return new \WP_Error( 'bloatwright_running', __( 'A session is already running.', 'bloatwright' ), array( 'status' => 409 ) ); } $loader = $this->ensure_loader(); if ( is_wp_error( $loader ) ) { return $loader; } // The session it was started from is its baseline: the before/after compares with that one. $id = $this->runner->start( $s['urls'], (int) $s['passes'], $plugin, sprintf( /* translators: 1: session id, 2: plugin */ __( 'Session #%1$d without %2$s', 'bloatwright' ), (int) $s['id'], $plugin ), (int) $s['id'] ); if ( is_wp_error( $id ) ) { return self::with_status( $id ); } return new \WP_REST_Response( $this->sessions->find( $id ), 201 ); } /** * Compare two sessions. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function compare( \WP_REST_Request $request ) { $a = $this->sessions->find( (int) $request['other'] ); $b = $this->sessions->find( (int) $request['id'] ); if ( ! $a || ! $b || empty( $a['summary'] ) || empty( $b['summary'] ) ) { return new \WP_Error( 'bloatwright_not_found', __( 'Both sessions must be finished.', 'bloatwright' ), array( 'status' => 404 ) ); } return new \WP_REST_Response( Diff::compare( (array) $a['summary'], (array) $b['summary'] ) + array( 'a' => (int) $a['id'], 'b' => (int) $b['id'], ) ); } /** * Start browse mode (refused with 409 while a recording or an automatic run is in progress). * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function browse_start( \WP_REST_Request $request ) { $loader = $this->ensure_loader(); if ( is_wp_error( $loader ) ) { return $loader; } $r = $this->browse->start( sanitize_text_field( (string) $request->get_param( 'exclude' ) ) ); if ( is_wp_error( $r ) ) { return $r; } unset( $r['token'] ); // It travels only in the HttpOnly cookie. return new \WP_REST_Response( $r, 201 ); } /** * Stop browse mode (also a recording whose cookie has just expired but that is not finished yet). * * @return \WP_REST_Response|\WP_Error */ public function browse_stop() { $id = $this->browse->pending(); if ( ! $id ) { return new \WP_Error( 'bloatwright_not_running', __( 'Browse mode is not active.', 'bloatwright' ), array( 'status' => 409 ) ); } $s = $this->browse->stop( $id ); if ( is_wp_error( $s ) ) { return $s; } unset( $s['summary'] ); return new \WP_REST_Response( $s ); } }