[ label, WooCommerce item type or '' for the order ]. * * @since 1.0.0 * @return array */ public static function sections() { return array( 'order' => array( 'label' => __( 'Order', 'admin-powerups-for-woocommerce' ), 'type' => '', ), 'items' => array( 'label' => __( 'Line items', 'admin-powerups-for-woocommerce' ), 'type' => 'line_item', ), 'shipping' => array( 'label' => __( 'Shipping', 'admin-powerups-for-woocommerce' ), 'type' => 'shipping', ), 'coupons' => array( 'label' => __( 'Coupons', 'admin-powerups-for-woocommerce' ), 'type' => 'coupon', ), 'fees' => array( 'label' => __( 'Fees', 'admin-powerups-for-woocommerce' ), 'type' => 'fee', ), ); } /** * {@inheritDoc} */ public function id() { return 'order-meta'; } /** * {@inheritDoc} */ public function title() { return __( 'Order meta inspector', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function description() { return __( 'Every piece of data on an order: meta in tabs and the whole order as JSON. Secrets stay masked.', 'admin-powerups-for-woocommerce' ); } /** * {@inheritDoc} */ public function category() { return 'developer'; } /** * {@inheritDoc} * * Read-only: it shows data and never changes the order. */ public function risk(): string { return DevGuard::SAFE; } /** * {@inheritDoc} */ public function autoload_settings() { return false; // Only read on the order screen and its REST route. } /** * {@inheritDoc} */ public function icon() { return 'braces'; } /** * {@inheritDoc} */ public function location() { return __( 'Order page', 'admin-powerups-for-woocommerce' ); } /** * Default sensitive key patterns. * * @since 1.0.0 * @return string */ public static function default_sensitive() { return implode( "\n", MetaMask::default_patterns() ); } /** * {@inheritDoc} * * @return array> */ public function fields() { return array( array( 'id' => 'only_hidden', 'type' => 'toggle', 'label' => __( 'Show hidden fields only', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Hidden fields start with an underscore. Others already appear under Custom Fields.', 'admin-powerups-for-woocommerce' ), 'default' => false, ), array( 'id' => 'exclude', 'type' => 'textarea', 'label' => __( 'Fields to leave out', 'admin-powerups-for-woocommerce' ), 'description' => __( 'One per line. Use * as a wildcard.', 'admin-powerups-for-woocommerce' ), 'default' => "_edit_lock\n_edit_last", ), array( 'id' => 'sensitive', 'type' => 'textarea', 'label' => __( 'Sensitive fields to mask', 'admin-powerups-for-woocommerce' ), 'description' => __( 'One per line. Use * as a wildcard. Matching values are masked in the box, always in the JSON view, and in scheduled action and cron arguments.', 'admin-powerups-for-woocommerce' ), 'default' => self::default_sensitive(), ), array( 'id' => 'reveal', 'type' => 'select', 'label' => __( 'Who can see masked values', 'admin-powerups-for-woocommerce' ), 'description' => __( 'Only admins see this box. “Shop managers and admins” also lets shop managers search masked fields in Advanced order search.', 'admin-powerups-for-woocommerce' ), 'default' => 'manage_options', 'options' => array( 'manage_options' => __( 'Admins only', 'admin-powerups-for-woocommerce' ), 'manage_woocommerce' => __( 'Shop managers and admins', 'admin-powerups-for-woocommerce' ), 'none' => __( 'Nobody', 'admin-powerups-for-woocommerce' ), ), ), ); } /** * {@inheritDoc} */ public function boot() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); if ( is_admin() ) { add_action( 'add_meta_boxes', array( $this, 'add_box' ), 40 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_filter( 'get_user_option_closedpostboxes_' . Screen::order_screen_id(), array( $this, 'closed_by_default' ) ); } } /** * Whether the current user may use the box and its routes: a site admin (this is a Developer * power-up) who can edit orders. * * @since 1.0.0 * @return bool */ public function user_can_inspect(): bool { return DevGuard::can( $this, DevGuard::SAFE ) && current_user_can( 'edit_shop_orders' ); } /** * Start collapsed until the user opens or closes any box on this screen (WordPress then remembers). * * @since 1.0.0 * @param mixed $closed Closed boxes, or false when the user never saved a preference. * @return mixed */ public function closed_by_default( $closed ) { return false === $closed ? array( self::BOX ) : $closed; } /** * Register the meta box. * * @since 1.0.0 * @return void */ public function add_box() { if ( ! $this->user_can_inspect() ) { return; } add_meta_box( self::BOX, __( 'Order meta', 'admin-powerups-for-woocommerce' ), array( $this, 'render_box' ), Screen::order_screen_id(), 'normal', 'low' ); } /** * Enqueue assets. * * @since 1.0.0 * @return void */ public function enqueue() { if ( Screen::is_order_edit() && $this->user_can_inspect() ) { wp_enqueue_style( 'apwc-modules', Assets::url( 'assets/css/modules.css' ), array(), Assets::ver( 'assets/css/modules.css' ) ); // apwc-dev-tools (JSON viewer, copy) is registered on every admin screen by AdminPage. wp_enqueue_script( 'apwc-order-meta', Assets::url( 'assets/js/order-meta.js' ), array( 'wp-api-fetch', 'wp-i18n', 'apwc-dev-tools' ), Assets::ver( 'assets/js/order-meta.js' ), true ); wp_set_script_translations( 'apwc-order-meta', 'admin-powerups-for-woocommerce' ); } } /** * Whether the current user may reveal masked values. * * @since 1.0.0 * @return bool */ public function can_reveal() { return MetaMask::can_reveal(); } /** * Sensitive key patterns the current user may not see, for other features that read order * meta. Empty when they can reveal masked values. Uses this power-up's saved settings * whether it is on or off. * * @since 1.0.0 * @see MetaMask::masked_for_current_user() * @return string[] Wildcard patterns ("*" = anything). */ public static function masked_patterns() { return MetaMask::masked_for_current_user(); } /** * Render the box. * * @since 1.0.0 * @param WC_Order|\WP_Post $post_or_order Order (HPOS) or post (legacy). * @return void */ public function render_box( $post_or_order ) { $order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID ); if ( ! $order instanceof WC_Order ) { return; } if ( ! $this->loads_later( $order ) && ! $this->is_closed() ) { $this->render_inspector( $order ); return; } // Closed box (the default) or big order: fetch the inspector over REST when the box is // opened (see order-meta.js), so nothing is built for a box nobody looks at. printf( '

%2$s

', esc_attr( (string) $order->get_id() ), esc_html__( 'Loading order meta…', 'admin-powerups-for-woocommerce' ) ); } /** * Whether the current user has this box closed on the order screen (the default until they * open it; see closed_by_default()). * * @since 1.0.0 * @return bool */ public function is_closed(): bool { $closed = get_user_option( 'closedpostboxes_' . Screen::order_screen_id() ); return is_array( $closed ) && in_array( self::BOX, $closed, true ); } /** * Whether the box loads its content when opened instead of with the page (orders with many items). * * @since 1.0.0 * @param WC_Order $order Order. * @return bool */ public function loads_later( WC_Order $order ) { /** * Most order items (line items, shipping, coupons, fees) for which the Order meta box is * built with the page. Bigger orders load it when the box is opened. * * @since 1.0.0 * @param int $max Item count. * @param WC_Order $order Order. */ $max = (int) apply_filters( 'apwc_order_meta_inline_items', 50, $order ); return count( $order->get_items( array( 'line_item', 'shipping', 'coupon', 'fee' ) ) ) > $max; } /** * The inspector's HTML (tabs, rows, masked values) for an order. * * @since 1.0.0 * @param WC_Order $order Order. * @return string Escaped HTML, for REST responses. */ public function inspector_html( WC_Order $order ) { ob_start(); $this->render_inspector( $order ); return (string) ob_get_clean(); } /** * Print the inspector's HTML (tabs, rows, masked values) for an order. * * @since 1.0.0 * @param WC_Order $order Order. * @return void */ public function render_inspector( WC_Order $order ) { $data = $this->inspect( $order ); $can_reveal = $this->can_reveal(); include __DIR__ . '/views/box.php'; } /** * Everything the inspector shows, by section. * * @since 1.0.0 * @param WC_Order $order Order. * @return array> Section key => label, count, groups (each: title, item_id, rows). */ public function inspect( WC_Order $order ) { $data = array(); foreach ( self::sections() as $key => $section ) { $groups = array(); if ( '' === $section['type'] ) { $groups[] = array( 'title' => '', 'item_id' => 0, 'rows' => $this->rows( $order ), ); } else { foreach ( $order->get_items( $section['type'] ) as $item ) { $groups[] = array( 'title' => $this->item_title( $item ), 'item_id' => (int) $item->get_id(), 'rows' => $this->build_rows( $item->get_meta_data() ), ); } } $data[ $key ] = array( 'label' => $section['label'], 'count' => array_sum( array_map( static fn( $g ) => count( $g['rows'] ), $groups ) ), 'groups' => $groups, ); } /** * Filter everything the order meta inspector shows. * * @since 1.0.0 * @param array> $data Section key => [ label, count, groups ]. * @param WC_Order $order Order. */ return (array) apply_filters( 'apwc_order_meta_sections', $data, $order ); } /** * Order-level rows (kept as its own method for add-ons and tests). * * @since 1.0.0 * @param WC_Order $order Order. * @return array> */ public function rows( WC_Order $order ) { /** * Filter the order-level rows in the inspector. * * @since 1.0.0 * @param array> $rows Rows. * @param WC_Order $order Order. */ return (array) apply_filters( 'apwc_order_meta_rows', $this->build_rows( $order->get_meta_data() ), $order ); } /** * Heading for an item group, e.g. "Blue Hoodie × 2". * * @since 1.0.0 * @param WC_Order_Item $item Item. * @return string */ private function item_title( WC_Order_Item $item ) { $name = wp_strip_all_tags( $item->get_name() ); if ( 'line_item' === $item->get_type() && is_callable( array( $item, 'get_quantity' ) ) ) { /* translators: 1: item name, 2: quantity. */ return sprintf( __( '%1$s × %2$d', 'admin-powerups-for-woocommerce' ), $name, (int) $item->get_quantity() ); } return '' !== $name ? $name : __( '(no name)', 'admin-powerups-for-woocommerce' ); } /** * Rows from WC_Meta_Data objects, filtered and sorted. * * @since 1.0.0 * @param \WC_Meta_Data[] $meta Meta. * @return array */ private function build_rows( array $meta ) { $exclude = $this->patterns( 'exclude' ); $sensitive = $this->patterns( 'sensitive' ); if ( ! $this->can_reveal() ) { $sensitive = array_merge( $sensitive, OrderJson::reveal_only_patterns() ); } $only = (bool) $this->setting( 'only_hidden' ); $rows = array(); foreach ( $meta as $item ) { $key = (string) $item->key; $hidden = '_' === substr( $key, 0, 1 ); if ( ( $only && ! $hidden ) || self::matches( $key, $exclude ) ) { continue; } // WC_Data::read_meta_data() already unserialized stored values once. Never unserialize // again: a string that merely looks serialized (e.g. shopper input) must stay a string. // A value holding a sensitive key anywhere inside (a gateway payload with an access // token) is masked whole, like a sensitive key. $safe = self::safe_value( $item->value ); $is_secret = self::matches( $key, $sensitive ) || self::holds_sensitive( $safe, $sensitive ); $rows[] = array( 'key' => $key, 'value' => $is_secret ? '' : self::display( $item->value ), 'json' => $is_secret ? null : $safe, 'hidden' => $hidden, 'complex' => is_array( $item->value ) || is_object( $item->value ), 'sensitive' => $is_secret, ); } usort( $rows, static fn( $a, $b ) => strnatcasecmp( ltrim( $a['key'], '_' ), ltrim( $b['key'], '_' ) ) ); return $rows; } /** * Whether a value (through safe_value()) has a key matching a pattern at any depth. * * @since 1.0.0 * @param mixed $value Value. * @param string[] $patterns Patterns. * @return bool */ private static function holds_sensitive( $value, array $patterns ) { if ( ! ( is_array( $value ) || is_string( $value ) ) || ! $patterns ) { return false; } $hits = array(); OrderJson::mask_nested( $value, $patterns, '', $hits ); return (bool) $hits; } /** * A meta value made safe to show and encode: objects become "[object Class]" labels (their * methods never run), long strings are cut and big arrays capped. * * @since 1.0.0 * @param mixed $value Value as WooCommerce read it. * @return mixed Null, scalar or array of those. */ public static function safe_value( $value ) { return SafeDump::to_array( $value, 32, self::MAX_VALUE_CHARS, self::MAX_VALUE_ITEMS ); } /** * Text for a value: JSON for arrays, else the string. Everything goes through safe_value() * first, so an object is never encoded itself and huge values are capped. * * @since 1.0.0 * @param mixed $value Value. * @return string */ public static function display( $value ) { $value = self::safe_value( $value ); if ( is_array( $value ) ) { return (string) wp_json_encode( $value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } return (string) $value; } /** * Setting lines as patterns. * * @since 1.0.0 * @param string $setting Setting ID. * @return string[] */ private function patterns( $setting ) { return MetaMask::lines( (string) $this->setting( $setting ) ); } /** * Whether a key matches any wildcard pattern (ignoring case and accents, like MySQL does). * * @since 1.0.0 * @param string $key Key. * @param string[] $patterns Patterns. * @return bool */ public static function matches( $key, array $patterns ) { return MetaMask::matches( (string) $key, $patterns ); } /* ------------------------------------------------------------------ REST */ /** * REST routes: the inspector for big orders, the order as JSON, and one masked value. All * need a site admin who can edit orders; reveal also needs the "Who can see masked values" * permission. * * @since 1.0.0 * @return void */ public function register_routes() { $order_arg = array( 'order' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1, ), ); $can = DevGuard::permission( $this, DevGuard::SAFE, 'edit_shop_orders' ); register_rest_route( 'apwc/v1', '/order-meta/box', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_box' ), 'permission_callback' => $can, 'args' => $order_arg, ) ); register_rest_route( 'apwc/v1', '/order-meta/json', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_json' ), 'permission_callback' => $can, 'args' => $order_arg, ) ); register_rest_route( 'apwc/v1', '/order-meta/reveal', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_reveal' ), 'permission_callback' => static function () use ( $can ): bool { return $can() && MetaMask::can_reveal(); }, 'args' => array_merge( $order_arg, array( 'item' => array( 'type' => 'integer', 'default' => 0, 'minimum' => 0, ), 'key' => array( 'type' => 'string', 'required' => true, ), ) ), ) ); } /** * GET json: the whole order as JSON (REST API shaped), masked for everyone. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_json( WP_REST_Request $request ) { $order = wc_get_order( absint( $request['order'] ) ); if ( ! $order instanceof WC_Order ) { return new \WP_Error( 'apwc_no_order', __( 'Order not found.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } $built = OrderJson::build( $order ); return rest_ensure_response( array( 'json' => $built['json'], 'masked' => $built['masked'], 'truncated' => (object) $built['truncated'], 'context' => OrderJson::context(), ) ); } /** * GET box: the inspector for a big order, loaded when the box is opened. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_box( WP_REST_Request $request ) { $order = wc_get_order( (int) $request['order'] ); if ( ! $order instanceof WC_Order ) { return new \WP_Error( 'apwc_no_order', __( 'Order not found.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } return rest_ensure_response( array( 'html' => $this->inspector_html( $order ) ) ); } /** * GET reveal. * * @since 1.0.0 * @param WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function rest_reveal( WP_REST_Request $request ) { $order = wc_get_order( (int) $request['order'] ); if ( ! $order instanceof WC_Order ) { return new \WP_Error( 'apwc_no_order', __( 'Order not found.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } $source = $order; if ( (int) $request['item'] ) { $source = $order->get_item( (int) $request['item'] ); // get_item() loads any item by ID: make sure it belongs to this order. if ( ! $source instanceof WC_Order_Item || (int) $source->get_order_id() !== (int) $order->get_id() ) { return new \WP_Error( 'apwc_no_item', __( 'Item not found on this order.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } } $key = (string) $request['key']; foreach ( $source->get_meta_data() as $meta ) { if ( $meta->key === $key ) { // Already unserialized by WC_Data; never unserialize twice. Objects become labels. return rest_ensure_response( array( 'value' => self::display( $meta->value ), 'json' => self::safe_value( $meta->value ), ) ); } } return new \WP_Error( 'apwc_no_key', __( 'That field is not on this order.', 'admin-powerups-for-woocommerce' ), array( 'status' => 404 ) ); } }