valid() || ! self::current( $origin ) || $scope->site_id !== (int) get_current_blog_id() || $scope->network_id !== Scope::network() || ! self::current( $origin ) ) { self::$conflicted = true; return; } self::$buffered_scope = $scope; self::$buffered_origin = $origin; } catch ( \Throwable $error ) { self::$conflicted = true; } finally { self::$preparing = false; } } /** * Build a native editor's read-only item-history shortcut without reading titles. * * @param mixed $object Native WordPress post or WooCommerce order. * @return string Fixed local admin URL after fresh object and event permissions. */ public static function history_url( mixed $object ): string { if ( self::$resolving || self::$preparing || self::$validating ) { return ''; } self::$resolving = true; self::$native_history = true; try { $db = $GLOBALS['wpdb'] ?? null; if ( ! $db instanceof \wpdb || get_current_user_id() < 1 || is_network_admin() ) { return ''; } $origin = Derived::native_context( $db ); $scope = Scope::current(); if ( ! $scope instanceof Scope || ! self::current( $origin ) ) { return ''; } $id = $object instanceof \WP_Post ? (int) $object->ID : ( $object instanceof \WC_Order ? (int) $object->get_id() : 0 ); $type = ''; $native_grants = null; if ( $id < 1 || ! self::current( $origin ) ) { return ''; } if ( $object instanceof \WC_Order || ( $object instanceof \WP_Post && 'shop_order' === $object->post_type ) ) { $model = self::order( $id, $origin, self::unavailable( 'order' ) ); if ( 'available' !== $model['state'] ) { return ''; } $type = 'order'; } elseif ( $object instanceof \WP_Post ) { $native_grants = self::history_post_grants( $id, $origin ); if ( null === $native_grants ) { return ''; } $post = get_post( $id ); if ( ! self::current( $origin ) || ! $post instanceof \WP_Post || $post->post_type !== $object->post_type || in_array( $post->post_status, array( 'auto-draft', 'trash' ), true ) || in_array( $post->post_type, array( 'revision', 'shop_order_refund', 'shop_order_placehold', 'shop_coupon' ), true ) || str_starts_with( $post->post_type, 'wc_' ) || str_starts_with( $post->post_type, 'logged_ai' ) ) { return ''; } $type = in_array( $post->post_type, array( 'product', 'product_variation' ), true ) ? 'product' : 'post'; $confirmed = self::history_post_grants( $id, $origin ); if ( null === $confirmed ) { return ''; } $native_grants = array_values( array_unique( array_merge( $native_grants, $confirmed ) ) ); } if ( '' === $type || ! self::current( $origin ) || ! ( null === $native_grants ? $scope->valid() : $scope->valid_for_native( $native_grants ) ) || ! self::current( $origin ) ) { return ''; } if ( null !== $native_grants ) { $changed = false; $observe = static function ( string $sql ) use ( &$changed ): string { if ( ! preg_match( '/\A(?:SELECT|SHOW|DESCRIBE|EXPLAIN)\s/i', trim( $sql ) ) ) { $changed = true; } return $sql; }; // Native final meta-capability callbacks may write while WP_User stays stale. // Observe only this phase; do not intercept SQL or alter the caller's data. add_filter( 'query', $observe, PHP_INT_MAX ); try { $final_grants = self::history_post_grants( $id, $origin ); } finally { remove_filter( 'query', $observe, PHP_INT_MAX ); } if ( $changed || null === $final_grants || array_diff( $final_grants, $native_grants ) || ! self::current( $origin ) ) { return ''; } } return 'admin.php?page=logged-ai&object_type=' . $type . '&object_id=' . $id; } catch ( \Throwable $error ) { return ''; } finally { self::$resolving = false; self::$native_history = false; } } /** * Collect native post permissions for one final fresh, coherent grant check. * * Native object decisions are not cached, and no URL is returned until the * scope checks all collected primitive grants together. Orders keep their * existing alternative permission paths. * * @param int $id Native post identifier. * @param array $origin Pinned native caller context. * @return array|null Bounded mapped primitive names, or a denied decision. */ private static function history_post_grants( int $id, array $origin ): ?array { $required = array(); foreach ( array( 'read_post', 'edit_post' ) as $capability ) { if ( ! self::current( $origin ) || ! current_user_can( $capability, $id ) || ! self::current( $origin ) ) { return null; } $mapped = map_meta_cap( $capability, get_current_user_id(), $id ); if ( ! self::current( $origin ) || ! is_array( $mapped ) || ! $mapped || count( $mapped ) > 12 ) { return null; } foreach ( $mapped as $primitive ) { if ( ! is_string( $primitive ) || 'do_not_allow' === $primitive || ! preg_match( '/\A[a-zA-Z0-9_]{1,100}\z/D', $primitive ) ) { return null; } $required[ $primitive ] = true; } if ( count( $required ) > 12 || ! self::current( $origin ) || ! current_user_can( $capability, $id ) || ! self::current( $origin ) ) { return null; } } return array_keys( $required ); } /** * Recheck current titles, object permissions and destinations before output. * * @return bool Whether every disclosed model is still identical and authorized. */ public static function valid(): bool { if ( self::$resolving || self::$validating || self::$preparing || self::$conflicted ) { return false; } self::$validating = true; try { // Initial-render approvals never authorize the final disclosure pass. self::$primitive_grants = array(); if ( null !== self::$buffered_scope && ( ! self::current( self::$buffered_origin ) || ! self::$buffered_scope->valid() || ! self::current( self::$buffered_origin ) ) ) { return false; } foreach ( self::$disclosures as $disclosure ) { if ( self::resolve( $disclosure['event'] ) !== $disclosure['model'] ) { return false; } } return ! self::$conflicted && ( null === self::$buffered_scope || ( self::current( self::$buffered_origin ) && self::$buffered_scope->valid() && self::current( self::$buffered_origin ) ) ) && ! self::$conflicted; } catch ( \Throwable $error ) { return false; } finally { self::$validating = false; self::$primitive_grants = array(); } } /** * Build an ephemeral view model from an already-authorized native event. * * Never call this from a persistent projection, REST serializer or export. * All returned strings still require escaping by the HTML renderer. Relative * destinations are fixed current-admin endpoints, never filtered remote URLs. * * @param array $event Decoded retained event from the current site. * @return array Empty for unsupported evidence, otherwise a current view model. */ public static function resolve( array $event ): array { $types = array( 'content.post_created' => array( 'post', 'content', 'core_allowlisted_v1' ), 'content.post_changed' => array( 'post', 'content', 'core_allowlisted_v1' ), 'content.post_deleted' => array( 'post', 'content', 'core_allowlisted_v1' ), 'commerce.product_created' => array( 'product', 'product', 'woocommerce_crud_v1' ), 'commerce.product_changed' => array( 'product', 'product', 'woocommerce_crud_v1' ), 'commerce.stock_changed' => array( 'product', 'product', 'woocommerce_crud_v1' ), 'commerce.order_created' => array( 'order', 'order', 'woocommerce_crud_v1' ), 'commerce.order_changed' => array( 'order', 'order', 'woocommerce_crud_v1' ), 'commerce.order_deleted' => array( 'order', 'order', 'woocommerce_crud_v1' ), ); $code = $event['code'] ?? null; $detail = $event['detail'] ?? null; if ( self::$resolving || self::$preparing || ! is_string( $code ) || ! isset( $types[ $code ] ) || ! is_array( $detail ) || array_key_exists( 'extension', $detail ) || $types[ $code ][0] !== ( $event['object_type'] ?? null ) || $types[ $code ][2] !== ( $detail['collector'] ?? null ) || 'site' !== ( $detail['event_scope'] ?? null ) || ! in_array( $detail['source'] ?? null, array( 'wordpress', 'synthetic_test' ), true ) || 'stored_change' !== ( $event['outcome'] ?? null ) || ! is_array( $detail['facts'] ?? null ) ) { return array(); } $id = self::identifier( $event['object_id'] ?? null ); $site = self::identifier( $event['site_id'] ?? null ); $network = self::identifier( $event['network_id'] ?? null ); if ( ! $id || ! $site || ! $network || $site !== (int) get_current_blog_id() || $network !== Scope::network() ) { return array(); } self::$resolving = true; try { $model = self::unavailable( $types[ $code ][1] ); global $wpdb; if ( ! $wpdb instanceof \wpdb || get_current_user_id() < 1 ) { return $model; } $origin = Derived::native_context( $wpdb ); $scope = self::$buffered_scope ?? Scope::current(); if ( ! $scope instanceof Scope || ! self::current( $origin ) || ( null !== self::$buffered_scope && self::$buffered_origin !== $origin ) || $site !== $scope->site_id || $network !== $scope->network_id ) { return $model; } // A deletion record must never be linked to a later object reusing its ID. if ( in_array( $code, array( 'content.post_deleted', 'commerce.order_deleted' ), true ) ) { $model['message'] = __( 'This record describes a deleted item. Its content is unavailable.', 'logged-ai' ); return $model; } if ( 'content' === $model['kind'] ) { $resolved = self::content( $event, $id, $origin, $model ); } elseif ( 'product' === $model['kind'] ) { $resolved = self::product( $id, $origin, $model ); } else { $resolved = self::order( $id, $origin, $model ); } if ( ! self::current( $origin ) || ( null === self::$buffered_scope && ! $scope->valid() ) || ! self::current( $origin ) ) { return $model; } if ( 'available' === $resolved['state'] && ! self::$validating ) { $key = hash( 'sha256', serialize( $event ) ); if ( isset( self::$disclosures[ $key ] ) && self::$disclosures[ $key ]['model'] !== $resolved ) { self::$conflicted = true; } else { self::$disclosures[ $key ] = array( 'event' => $event, 'model' => $resolved ); } } return $resolved; } catch ( \Throwable $error ) { // An optional destination must not break the retained evidence viewer. return self::unavailable( $types[ $code ][1] ); } finally { self::$resolving = false; } } /** * Make unavailable and restricted objects indistinguishable to unauthorized viewers. * * @param string $kind Fixed supported object kind. * @return array Empty current context with a generic explanation. */ private static function unavailable( string $kind ): array { return array( 'state' => 'unavailable', 'kind' => $kind, 'title' => '', 'title_label' => __( 'Current title', 'logged-ai' ), 'message' => __( 'This item is unavailable or you do not have permission to open it.', 'logged-ai' ), 'url' => '', 'action_label' => '', 'revision_url' => '', 'revision_label' => __( 'View revision', 'logged-ai' ), 'revision_message' => '', ); } /** * Accept only native positive identifiers without lossy coercion. * * @param mixed $value Retained identifier. * @return int Positive identifier, or zero. */ private static function identifier( mixed $value ): int { if ( is_int( $value ) ) { return max( 0, $value ); } if ( ! is_string( $value ) || ! preg_match( '/\A[1-9][0-9]{0,18}\z/D', $value ) ) { return 0; } $id = (int) $value; return (string) $id === $value ? $id : 0; } /** * Compare pure native pointer/identity/table state after callback-bearing operations. * * @param array $origin Captured current native context. * @return bool Whether the original context remains selected. */ private static function current( array $origin ): bool { $db = $GLOBALS['wpdb'] ?? null; return $db instanceof \wpdb && Derived::native_context( $db ) === $origin; } /** * Require native object capabilities plus fresh grants for their mapped primitives. * * @param string $capability Fixed native meta capability. * @param int $id Current object identifier. * @param array $origin Captured native context. * @return bool Whether native and fresh grants agree without context drift. */ private static function allowed( string $capability, int $id, array $origin ): bool { if ( ! self::current( $origin ) || ! current_user_can( $capability, $id ) || ! self::current( $origin ) ) { return false; } $mapped = map_meta_cap( $capability, get_current_user_id(), $id ); if ( ! self::current( $origin ) || ! is_array( $mapped ) || ! $mapped || count( $mapped ) > 12 ) { return false; } foreach ( $mapped as $primitive ) { if ( ! is_string( $primitive ) || 'do_not_allow' === $primitive || ! preg_match( '/\A[a-zA-Z0-9_]{1,100}\z/D', $primitive ) || ! self::primitive( $primitive, $origin ) || ! self::current( $origin ) ) { return false; } } return self::current( $origin ) && current_user_can( $capability, $id ) && self::current( $origin ); } /** * Reuse positive grants only within one explicitly pinned buffered phase. * * Native object capability/filter decisions are never cached. The final * phase starts empty under Store's write observer and refreshes every used * primitive; any intervening write makes the complete HTML buffer invalid. * * @param string $capability Validated primitive capability. * @param array $origin Current native context. * @return bool Whether the primitive remains authorized for this phase. */ private static function primitive( string $capability, array $origin ): bool { if ( self::$native_history || null === self::$buffered_scope ) { return Scope::can( $capability ); } if ( self::$conflicted || self::$buffered_origin !== $origin || ! self::current( $origin ) ) { return false; } if ( isset( self::$primitive_grants[ $capability ] ) ) { return true; } if ( ! Scope::can( $capability ) || ! self::current( $origin ) || self::$conflicted ) { return false; } self::$primitive_grants[ $capability ] = true; return true; } /** * Resolve supported WordPress editor objects and an explicitly retained revision. * * @param array $event Retained native event. * @param int $id Retained object ID. * @param array $origin Captured native context. * @param array $model Generic unavailable model. * @return array Permission-checked current model. */ private static function content( array $event, int $id, array $origin, array $model ): array { if ( ! self::allowed( 'read_post', $id, $origin ) || ! self::allowed( 'edit_post', $id, $origin ) ) { return $model; } $post = get_post( $id ); $type = $event['detail']['facts']['post_type'] ?? null; if ( ! self::current( $origin ) || ! $post instanceof \WP_Post || (int) $post->ID !== $id || ! is_string( $type ) || $type !== $post->post_type || in_array( $type, array( 'revision', 'shop_order', 'shop_order_refund', 'shop_order_placehold', 'product_variation' ), true ) ) { return $model; } $registered = get_post_type_object( $type ); if ( ! $registered || ! $registered->show_ui || 'post.php?post=%d' !== $registered->_edit_link || 'auto-draft' === $post->post_status ) { return $model; } if ( 'trash' === $post->post_status ) { $model['message'] = __( 'This item is in Trash. Restore it in WordPress before opening its editor.', 'logged-ai' ); return $model; } $model['state'] = 'available'; $model['title'] = self::title( $post->post_title ); $model['message'] = __( 'Current content can differ from what existed when this activity was recorded.', 'logged-ai' ); $model['url'] = 'post.php?post=' . $id . '&action=edit'; $model['action_label'] = __( 'Open content', 'logged-ai' ); if ( 'content.post_changed' === $event['code'] ) { $model['revision_message'] = __( 'No accessible WordPress revision is linked to this event. Logged.ai does not retain article text.', 'logged-ai' ); $revision_id = self::identifier( $event['detail']['facts']['revision_id'] ?? null ); if ( $revision_id && self::allowed( 'read_post', $revision_id, $origin ) && self::allowed( 'edit_post', $revision_id, $origin ) ) { $revision = wp_get_post_revision( $revision_id ); if ( self::current( $origin ) && $revision instanceof \WP_Post && (int) $revision->ID === $revision_id && (int) $revision->post_parent === $id && ! wp_is_post_autosave( $revision ) && wp_revisions_enabled( $post ) && self::current( $origin ) ) { $model['revision_url'] = 'revision.php?revision=' . $revision_id; $model['revision_message'] = __( 'Opens the WordPress revision referenced by this event. It is not a complete before-and-after copy stored by Logged.ai.', 'logged-ai' ); } } } if ( ! self::allowed( 'read_post', $id, $origin ) || ! self::allowed( 'edit_post', $id, $origin ) ) { return self::unavailable( 'content' ); } return $model; } /** * Resolve WooCommerce products, including their parent editor for variations. * * @param int $id Retained product ID. * @param array $origin Captured native context. * @param array $model Generic unavailable model. * @return array Permission-checked current product model. */ private static function product( int $id, array $origin, array $model ): array { if ( ! function_exists( 'wc_get_product' ) || ! self::allowed( 'read_post', $id, $origin ) || ! self::allowed( 'edit_post', $id, $origin ) ) { return $model; } $product = wc_get_product( $id ); if ( ! self::current( $origin ) || ! $product instanceof \WC_Product || (int) $product->get_id() !== $id ) { return $model; } if ( 'trash' === $product->get_status( 'edit' ) ) { $model['message'] = __( 'This product is in Trash. Restore it in WooCommerce before opening its editor.', 'logged-ai' ); return $model; } $target = $id; if ( $product instanceof \WC_Product_Variation ) { $target = (int) $product->get_parent_id( 'edit' ); if ( $target < 1 || ! self::allowed( 'read_post', $target, $origin ) || ! self::allowed( 'edit_post', $target, $origin ) ) { return $model; } $parent = wc_get_product( $target ); if ( ! self::current( $origin ) || ! $parent instanceof \WC_Product || $parent instanceof \WC_Product_Variation || (int) $parent->get_id() !== $target || 'trash' === $parent->get_status( 'edit' ) ) { return $model; } } $model['state'] = 'available'; $model['title'] = self::title( $product->get_name( 'edit' ) ); $model['message'] = $target === $id ? __( 'Current product details can differ from the recorded activity.', 'logged-ai' ) : __( 'This is a product variation. Its editor is inside the parent product.', 'logged-ai' ); $model['url'] = 'post.php?post=' . $target . '&action=edit'; $model['action_label'] = $target === $id ? __( 'Open product', 'logged-ai' ) : __( 'Open parent product', 'logged-ai' ); if ( ! self::allowed( 'read_post', $id, $origin ) || ! self::allowed( 'edit_post', $id, $origin ) || ! self::allowed( 'read_post', $target, $origin ) || ! self::allowed( 'edit_post', $target, $origin ) ) { return self::unavailable( 'product' ); } return $model; } /** * Resolve order editors through the active native HPOS or legacy storage mode. * * No customer names, addresses, order numbers or order titles are requested. * * @param int $id Retained order ID. * @param array $origin Captured native context. * @param array $model Generic unavailable model. * @return array Permission-checked current order model. */ private static function order( int $id, array $origin, array $model ): array { if ( ! function_exists( 'wc_get_order' ) || ! class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) ) { return $model; } $hpos = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); if ( ! self::current( $origin ) || ! self::order_allowed( $id, $hpos, $origin ) ) { return $model; } $order = wc_get_order( $id ); if ( ! self::current( $origin ) || ! $order instanceof \WC_Order || (int) $order->get_id() !== $id || 'shop_order' !== $order->get_type() ) { return $model; } if ( 'trash' === $order->get_status( 'edit' ) ) { $model['message'] = __( 'This order is in Trash. Restore it in WooCommerce before opening its editor.', 'logged-ai' ); return $model; } $model['state'] = 'available'; $model['message'] = __( 'Opens the current WooCommerce order. Customer information is not added to this activity record.', 'logged-ai' ); $model['url'] = $hpos ? 'admin.php?page=wc-orders&action=edit&id=' . $id : 'post.php?post=' . $id . '&action=edit'; $model['action_label'] = __( 'Open order', 'logged-ai' ); if ( ! self::order_allowed( $id, $hpos, $origin ) || $hpos !== \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() || ! self::current( $origin ) ) { return self::unavailable( 'order' ); } return $model; } /** * Match native order editor admission in the selected storage mode. * * HPOS additionally accepts WooCommerce managers after its list capability; * the legacy WordPress editor still requires the object's edit capability. * * @param int $id Current order ID. * @param bool $hpos Captured native order storage mode. * @param array $origin Captured native context. * @return bool Whether the selected native order editor authorizes this user. */ private static function order_allowed( int $id, bool $hpos, array $origin ): bool { return self::allowed( 'edit_shop_orders', $id, $origin ) && ( self::allowed( 'edit_shop_order', $id, $origin ) || ( $hpos && self::allowed( 'manage_woocommerce', $id, $origin ) ) ); } /** * Bound a current title as plain text without applying display-title filters. * * @param mixed $value Current native title or product name. * @return string Plain current title, bounded to 160 Unicode characters. */ private static function title( mixed $value ): string { if ( ! is_string( $value ) || strlen( $value ) > 1048576 ) { return __( '(Untitled)', 'logged-ai' ); } $text = wp_strip_all_tags( html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); $text = preg_replace( '/[\x00-\x1f\x7f\x{200B}-\x{200F}\x{202A}-\x{202E}\x{2060}-\x{206F}\x{FEFF}]/u', ' ', $text ); $text = is_string( $text ) ? trim( preg_replace( '/\s+/u', ' ', $text ) ?? '' ) : ''; if ( '' === $text || ! preg_match( '/\A.{1,160}/us', $text, $match ) ) { return __( '(Untitled)', 'logged-ai' ); } return $match[0] . ( $match[0] === $text ? '' : '…' ); } }