__( 'Autoloaded options', 'admin-powerups-for-woocommerce' ) ); } /** * {@inheritDoc} * * @since 1.0.0 * @return array> */ public function data() { return array_merge( parent::data(), array( Cleanup::option( 'history', History::OPTION, __( 'Change history', 'admin-powerups-for-woocommerce' ), __( 'The list of options you stopped autoloading or deleted here (names, sizes, who and when; never values). Deleting it doesn’t undo those changes; “Autoload again” just won’t be offered for them any more.', 'admin-powerups-for-woocommerce' ) ), Cleanup::record( 'changes', __( 'Autoload changes and deleted options', 'admin-powerups-for-woocommerce' ), __( 'Options you stopped autoloading stay that way, and deleted options stay deleted, when the power-up is off or removed.', 'admin-powerups-for-woocommerce' ) ), ) ); } /** * {@inheritDoc} */ public function boot() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * A fresh reader (it keeps the sizes it read, so each screen or request gets its own). * * @since 1.0.0 * @return Scanner */ public function scanner() { return new Scanner(); } /** * Tab URL. * * @since 1.0.0 * @param array $args Query args (s, owner). * @return string */ public static function url( array $args = array() ) { return add_query_arg( array_filter( $args, static fn( $v ) => '' !== $v && null !== $v ), AdminPage::url( 'autoload-analyzer', 'options' ) ); } /** * {@inheritDoc} * * @since 1.0.0 * @param string $view View ID. */ public function render_view( $view ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only screen filters. $search = isset( $_GET['s'] ) && is_string( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $owner = isset( $_GET['owner'] ) && is_string( $_GET['owner'] ) ? sanitize_key( wp_unslash( $_GET['owner'] ) ) : ''; // phpcs:enable $filter = array( 's' => mb_substr( $search, 0, 191 ), 'owner' => in_array( $owner, Scanner::OWNER_FILTERS, true ) ? $owner : '', ); $scanner = $this->scanner(); $summary = $scanner->summary(); $list = $scanner->rows( $filter ); $can_delete = DevGuard::can( $this, DevGuard::ADVANCED ); include __DIR__ . '/views/options.php'; } /** * The totals card, as HTML. * * @since 1.0.0 * @return string */ public function summary_html() { $summary = $this->scanner()->summary(); ob_start(); include __DIR__ . '/views/summary.php'; return (string) ob_get_clean(); } /** * The recent changes section, as HTML. * * @since 1.0.0 * @return string Markup, for REST responses. */ public function changes_html() { ob_start(); $this->render_changes(); return (string) ob_get_clean(); } /** * Print the recent changes section, as HTML. * * @since 1.0.0 * @return void */ public function render_changes() { $changes = array_slice( History::all(), 0, self::CHANGES_SHOWN ); $names = array_values( array_unique( array_column( $changes, 'name' ) ) ); $current = $this->scanner()->stored_autoload( $names ); $users = array_values( array_unique( array_filter( array_column( $changes, 'user' ) ) ) ); if ( $users ) { cache_users( $users ); } $latest = array(); foreach ( $changes as $index => $change ) { $key = strtolower( $change['name'] ); if ( ! isset( $latest[ $key ] ) ) { $latest[ $key ] = $index; } } include __DIR__ . '/views/changes.php'; } /** * Owner as shown: "WordPress", "WooCommerce", a plugin or theme name, or "Unknown", and its * state ("Inactive plugin", "Not installed", …). * * @since 1.0.0 * @param array $owner Owner guess. * @return array{label: string, state: string, guess: bool} */ public static function owner_text( array $owner ): array { $type = $owner['type'] ?? 'unknown'; $state = $owner['state'] ?? 'unknown'; switch ( $type ) { case 'core': $label = __( 'WordPress', 'admin-powerups-for-woocommerce' ); break; case 'woocommerce': $label = __( 'WooCommerce', 'admin-powerups-for-woocommerce' ); break; case 'plugin': case 'theme': $label = '' !== ( $owner['name'] ?? '' ) ? (string) $owner['name'] : (string) ( $owner['slug'] ?? '' ); break; default: $label = __( 'Unknown', 'admin-powerups-for-woocommerce' ); } $states = array( 'plugin' => array( 'active' => __( 'Active plugin', 'admin-powerups-for-woocommerce' ), 'inactive' => __( 'Inactive plugin', 'admin-powerups-for-woocommerce' ), 'not-installed' => __( 'Plugin not installed', 'admin-powerups-for-woocommerce' ), ), 'theme' => array( 'active' => __( 'Active theme', 'admin-powerups-for-woocommerce' ), 'inactive' => __( 'Inactive theme', 'admin-powerups-for-woocommerce' ), 'not-installed' => __( 'Theme not installed', 'admin-powerups-for-woocommerce' ), ), ); return array( 'label' => $label, 'state' => $states[ $type ][ $state ] ?? '', 'guess' => 'likely' === ( $owner['confidence'] ?? '' ), ); } /** * Autoload flag as shown. * * @since 1.0.0 * @param string $autoload Stored flag. * @return string */ public static function autoload_text( string $autoload ): string { if ( in_array( $autoload, array( 'on', 'yes' ), true ) ) { return __( 'Always', 'admin-powerups-for-woocommerce' ); } if ( in_array( $autoload, array( 'auto-on', 'auto' ), true ) ) { return __( 'Auto', 'admin-powerups-for-woocommerce' ); } if ( Scanner::is_autoloaded( $autoload ) ) { return __( 'Yes', 'admin-powerups-for-woocommerce' ); } return __( 'No', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} * * @since 1.0.0 * @param string $view View ID. */ public function enqueue_view_assets( $view ) { wp_enqueue_style( 'apwc-autoload-analyzer', Assets::url( 'assets/css/autoload-analyzer.css' ), array( 'apwc-admin' ), Assets::ver( 'assets/css/autoload-analyzer.css' ) ); wp_enqueue_script( 'apwc-autoload-analyzer', Assets::url( 'assets/js/autoload-analyzer.js' ), array( 'wp-api-fetch', 'wp-i18n', 'apwc-dev-tools', 'apwc-admin' ), Assets::ver( 'assets/js/autoload-analyzer.js' ), true ); wp_set_script_translations( 'apwc-autoload-analyzer', 'admin-powerups-for-woocommerce' ); } /* ------------------------------------------------------------------ REST */ /** * REST routes (registered only while the power-up is on). * * @since 1.0.0 * @return void */ public function register_routes() { $name = array( 'required' => true, 'type' => 'string', 'validate_callback' => array( __CLASS__, 'valid_name' ), ); register_rest_route( 'apwc/v1', '/autoload/list', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_list' ), 'permission_callback' => DevGuard::permission( $this, DevGuard::SAFE ), 'args' => array( 's' => array( 'type' => 'string', 'default' => '', 'validate_callback' => static fn( $v ) => is_string( $v ) && mb_strlen( $v ) <= 191, ), 'owner' => array( 'type' => 'string', 'default' => '', 'enum' => array_merge( array( '' ), Scanner::OWNER_FILTERS ), ), ), ) ); register_rest_route( 'apwc/v1', '/autoload/option', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_option' ), 'permission_callback' => DevGuard::permission( $this, DevGuard::SAFE ), 'args' => array( 'name' => $name, 'reveal' => array( 'type' => 'boolean', 'default' => false, ), 'full' => array( 'type' => 'boolean', 'default' => false, ), ), ) ); register_rest_route( 'apwc/v1', '/autoload/toggle', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_toggle' ), 'permission_callback' => DevGuard::permission( $this, DevGuard::REVIEW ), 'args' => array( 'name' => $name, 'autoload' => array( 'required' => true, 'type' => 'boolean', ), ), ) ); register_rest_route( 'apwc/v1', '/autoload/delete', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array( $this, 'rest_delete' ), 'permission_callback' => DevGuard::permission( $this, DevGuard::ADVANCED, '', 'delete' ), // Also checks the typed word (400). 'args' => array( 'name' => $name, 'confirm' => array( 'type' => 'string', 'default' => '', ), ), ) ); } /** * Whether a value can be an option name: text of 1 to 191 characters, valid UTF-8, no control * characters. * * @since 1.0.0 * @param mixed $value Value. * @return bool */ public static function valid_name( $value ) { if ( ! is_string( $value ) || '' === trim( $value ) || ! mb_check_encoding( $value, 'UTF-8' ) ) { return false; } return mb_strlen( $value, 'UTF-8' ) <= 191 && ! preg_match( '/[\x00-\x1f\x7f]/', $value ); } /** * GET /autoload/list: totals and the largest options for a filter. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response */ public function rest_list( WP_REST_Request $request ) { $scanner = $this->scanner(); $list = $scanner->rows( array( 's' => (string) $request['s'], 'owner' => (string) $request['owner'], ) ); return rest_ensure_response( array( 'summary' => $scanner->summary(), 'matched' => $list['matched'], 'rows' => $list['rows'], ) ); } /** * GET /autoload/option: one option for the viewer, or its full value for a download (`full`). * Only options this tool acts on are read (autoloaded, or switched off here); others are a * 409 `apwc_out_of_scope`. Values are sent as stored, never unserialized; sensitive ones only * with `reveal` and the Advanced level. Below Advanced the masking guess is stricter * (Policy::is_sensitive_below_advanced()). * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_option( WP_REST_Request $request ) { $option = $this->scanner()->raw( (string) $request['name'] ); if ( ! $option ) { return $this->not_found(); } if ( ! Scanner::is_autoloaded( $option['autoload'] ) && ! History::switched_off_here( $option['name'] ) ) { return $this->out_of_scope(); } $advanced = DevGuard::can( $this, DevGuard::ADVANCED ); $reveal = (bool) $request['reveal']; $full = (bool) $request['full']; $raw = $option['value']; $size = strlen( $raw ); $sensitive = $advanced ? Policy::is_sensitive( $option['name'], $raw ) : Policy::is_sensitive_below_advanced( $option['name'], $raw ); if ( ( $reveal || $full ) && ! $advanced ) { return new WP_Error( 'apwc_forbidden', __( 'Only admins who can make Advanced changes can reveal or download values.', 'admin-powerups-for-woocommerce' ), array( 'status' => 403 ) ); } if ( $full ) { if ( $sensitive && ! $reveal ) { return new WP_Error( 'apwc_masked', __( 'This value may hold secrets. Reveal it first.', 'admin-powerups-for-woocommerce' ), array( 'status' => 403 ) ); } if ( $size > self::DOWNLOAD_BYTES ) { return new WP_Error( 'apwc_too_large', /* translators: %s: value size, e.g. "2 MB". */ sprintf( __( 'This value is %s, too big to download here (1 MB at most). Use a database export instead.', 'admin-powerups-for-woocommerce' ), size_format( $size, 1 ) ), array( 'status' => 413, 'size' => $size, ) ); } $utf8 = mb_check_encoding( $raw, 'UTF-8' ); return rest_ensure_response( array( 'name' => $option['name'], 'size' => $size, 'encoding' => $utf8 ? 'utf8' : 'base64', 'value' => $utf8 ? $raw : base64_encode( $raw ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- binary-safe transport of a download. ) ); } $masked = $sensitive && ! $reveal; $owner = Policy::owner( $option['name'] ); $block = Policy::delete_block( $option['name'], $owner ); $kind = '' === $raw ? 'empty' : ( is_serialized( $raw ) ? 'serialized' : 'text' ); $json = null; if ( 'text' === $kind && $size <= self::JSON_BYTES && preg_match( '/^\s*[\[{]/', $raw ) ) { $decoded = json_decode( $raw, true, 64 ); if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) { $kind = 'json'; $json = $masked ? null : SafeDump::to_array( $decoded, 32, 2000, 1000 ); } } $shown = ''; if ( ! $masked ) { $shown = $size > self::PREVIEW_BYTES ? mb_strcut( $raw, 0, self::PREVIEW_BYTES, 'UTF-8' ) : $raw; $shown = mb_check_encoding( $shown, 'UTF-8' ) ? $shown : mb_scrub( $shown, 'UTF-8' ); } $owner_text = self::owner_text( $owner ); return rest_ensure_response( array( 'name' => $option['name'], 'size' => $size, 'size_text' => size_format( $size, 1 ), 'autoload' => $option['autoload'], 'autoload_text' => self::autoload_text( $option['autoload'] ), 'autoloaded' => Scanner::is_autoloaded( $option['autoload'] ), 'large' => $size > Scanner::option_limit( $option['name'] ), 'owner' => array_merge( $owner, $owner_text ), 'kind' => $kind, 'masked' => $masked, 'sensitive' => $sensitive, 'can_reveal' => $sensitive && $advanced, 'can_download' => $advanced && $size <= self::DOWNLOAD_BYTES, 'value' => $shown, 'truncated' => ! $masked && $size > self::PREVIEW_BYTES, 'json' => $json, 'toggle_block' => Policy::toggle_block( $option['name'] ), 'stop_note' => Policy::stop_note( $owner ), 'can_stop' => Scanner::is_autoloaded( $option['autoload'] ) && '' === Policy::toggle_block( $option['name'] ), 'delete_block' => $block, 'can_delete' => $advanced && '' === $block && ( Scanner::is_autoloaded( $option['autoload'] ) || History::switched_off_here( $option['name'] ) ), 'can_autoload' => ! Scanner::is_autoloaded( $option['autoload'] ) && History::switched_off_here( $option['name'] ), 'advanced_level' => $advanced, ) ); } /** * POST /autoload/toggle: stop autoloading an option (`autoload` false), or autoload again one * that was switched off here (`autoload` true). * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_toggle( WP_REST_Request $request ) { $option = $this->scanner()->raw( (string) $request['name'] ); if ( ! $option ) { return $this->not_found(); } $name = $option['name']; $on = (bool) $request['autoload']; $was = Scanner::is_autoloaded( $option['autoload'] ); if ( ! $on ) { $blocked = Policy::toggle_block( $name ); if ( '' !== $blocked ) { return new WP_Error( 'apwc_protected', $blocked, array( 'status' => 403 ) ); } if ( ! $was ) { return History::switched_off_here( $name ) ? new WP_Error( 'apwc_not_autoloaded', __( 'This option doesn’t autoload.', 'admin-powerups-for-woocommerce' ), array( 'status' => 409 ) ) : $this->out_of_scope(); } } else { if ( $was ) { return new WP_Error( 'apwc_autoloaded', __( 'This option already autoloads.', 'admin-powerups-for-woocommerce' ), array( 'status' => 409 ) ); } if ( ! History::switched_off_here( $name ) ) { return new WP_Error( 'apwc_not_in_history', __( 'Only options you stopped autoloading here can be switched back on.', 'admin-powerups-for-woocommerce' ), array( 'status' => 409 ) ); } } wp_set_option_autoload( $name, $on ); $after = $this->scanner()->raw( $name ); if ( ! $after || Scanner::is_autoloaded( $after['autoload'] ) !== $on ) { return new WP_Error( 'apwc_not_saved', __( 'The change wasn’t saved. Try again.', 'admin-powerups-for-woocommerce' ), array( 'status' => 500 ) ); } $owner = self::owner_text( Policy::owner( $name ) ); History::add( $name, $on ? 'on' : 'off', $option['autoload'], strlen( $option['value'] ), $owner['label'] ); /** * Fires after the Autoloaded options analyzer switched an option's autoload off or back on. * * @since 1.0.0 * @param string $name Option name. * @param bool $autoload True: autoloads again. False: stopped autoloading. */ do_action( 'apwc_option_autoload_changed', $name, $on ); return rest_ensure_response( array( 'name' => $name, 'autoload' => $after['autoload'], 'message' => $on /* translators: %s: option name. */ ? sprintf( __( '%s autoloads again.', 'admin-powerups-for-woocommerce' ), $name ) /* translators: %s: option name. */ : sprintf( __( 'Stopped autoloading %s.', 'admin-powerups-for-woocommerce' ), $name ), 'summary_html' => $this->summary_html(), 'changes_html' => $this->changes_html(), ) ); } /** * POST /autoload/delete: delete an option left by an inactive or removed plugin (Advanced; the * typed word is checked here too). * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|WP_Error */ public function rest_delete( WP_REST_Request $request ) { // The typed word was checked by the route's permission callback (DevGuard::permission()). $option = $this->scanner()->raw( (string) $request['name'] ); if ( ! $option ) { return $this->not_found(); } $name = $option['name']; $owner = Policy::owner( $name ); $blocked = Policy::delete_block( $name, $owner ); if ( '' !== $blocked ) { return new WP_Error( 'apwc_protected', $blocked, array( 'status' => 403 ) ); } if ( ! Scanner::is_autoloaded( $option['autoload'] ) && ! History::switched_off_here( $name ) ) { return $this->out_of_scope(); } delete_option( $name ); if ( $this->scanner()->raw( $name ) ) { return new WP_Error( 'apwc_not_saved', __( 'The option wasn’t deleted. Try again.', 'admin-powerups-for-woocommerce' ), array( 'status' => 500 ) ); } $text = self::owner_text( $owner ); History::add( $name, 'deleted', $option['autoload'], strlen( $option['value'] ), $text['label'] ); /** * Fires after the Autoloaded options analyzer deleted an option. * * @since 1.0.0 * @param string $name Option name. */ do_action( 'apwc_option_deleted', $name ); return rest_ensure_response( array( 'name' => $name, 'deleted' => true, /* translators: %s: option name. */ 'message' => sprintf( __( 'Deleted %s.', 'admin-powerups-for-woocommerce' ), $name ), 'summary_html' => $this->summary_html(), 'changes_html' => $this->changes_html(), ) ); } /** * 409 for an option this tool doesn't act on: not autoloaded, and not switched off here. * * @since 1.0.0 * @return WP_Error */ private function out_of_scope() { return new WP_Error( 'apwc_out_of_scope', __( 'This option doesn’t autoload, so this tool leaves it alone.', 'admin-powerups-for-woocommerce' ), array( 'status' => 409 ) ); } /** * 404 for an option that doesn't exist (any more). * * @since 1.0.0 * @return WP_Error */ private function not_found() { return new WP_Error( 'apwc_no_option', __( 'There’s no option with that name (it may have been deleted).', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } }