>, unparsed: int} Rows in file order. */ public static function parse_file_text( string $text ): array { $entries = array(); // Each: time, first line of the message, following lines. $current = -1; foreach ( self::lines( $text ) as $line ) { if ( preg_match( self::HEADER, $line, $m ) ) { $stamp = strtotime( $m[1] ); $entries[] = array( 'time' => false === $stamp ? 0 : $stamp, 'first' => $m[3], 'body' => array(), ); $current = count( $entries ) - 1; continue; } if ( $current >= 0 ) { $entries[ $current ]['body'][] = $line; } } $rows = array(); $unparsed = 0; foreach ( $entries as $entry ) { $row = 'Array' === rtrim( $entry['first'] ) ? self::parse_message( implode( "\n", array_merge( array( 'Array' ), $entry['body'] ) ) ) : null; if ( null === $row ) { ++$unparsed; continue; } $row['time'] = $entry['time']; $rows[] = $row; } return array( 'rows' => $rows, 'unparsed' => $unparsed, ); } /** * Parse one entry's message (the `wc_print_r()` dump, starting with "Array"). * * The raw_body flag: whether the request Body (the delivery array's own `[Body]` key, first * match) holds anything but BODY_PLACEHOLDER, i.e. the entry was logged with its bodies * (WP_DEBUG), so its response body could hold text shaped like log entries. True when that key * is missing. The request Body is printed before Response, so it's always WooCommerce's own * text, never the receiver's. The body text itself is never kept. * * @since 1.0.0 * @param string $message Message text. * @return array{delivery_id: string, webhook_id: int, duration: float|null, code: string, message: string, raw_body: bool}|null * Null when it isn't a webhook delivery entry in the expected shape. */ public static function parse_message( string $message ): ?array { $lines = self::lines( $message ); // The dump opens with the lines: Array / ( / [Webhook Delivery] => Array / ( at fixed indents. if ( count( $lines ) < 4 || 'Array' !== rtrim( $lines[0] ) || '(' !== rtrim( $lines[1] ) || ' [Webhook Delivery] => Array' !== rtrim( $lines[2] ) || ' (' !== rtrim( $lines[3] ) ) { return null; } $found = array(); $raw_body = null; // Null until the request Body is read. $section = ''; // Empty, or the request, request headers or response block. $count = count( $lines ); for ( $i = 4; $i < $count; $i++ ) { $line = $lines[ $i ]; // The delivery array closes: nothing after it belongs to this entry. if ( ' )' === $line ) { break; } // Keys of the delivery array itself (indent 12). if ( preg_match( '/^ {12}\[([A-Za-z ]+)\] => (.*)$/', $line, $m ) ) { $section = ''; $key = $m[1]; if ( 'Request' === $key && 'Array' === $m[2] ) { $section = 'request'; } elseif ( 'Response' === $key && 'Array' === $m[2] ) { $section = 'response'; } elseif ( 'Delivery ID' === $key && ! isset( $found['delivery_id'] ) ) { $found['delivery_id'] = $m[2]; } elseif ( 'Duration' === $key && ! isset( $found['duration'] ) ) { $found['duration'] = $m[2]; } elseif ( 'Body' === $key && null === $raw_body ) { $raw_body = self::BODY_PLACEHOLDER !== rtrim( $m[2] ); // Only whether, never the text. } continue; } if ( 'request' === $section && ' [Headers] => Array' === $line ) { $section = 'request-headers'; continue; } if ( 'request-headers' === $section ) { if ( ' )' === $line ) { $section = 'request'; // Headers closed. } elseif ( ! isset( $found['webhook_id'] ) && preg_match( '/^ {28}\[X-WC-Webhook-ID\] => (.*)$/', $line, $m ) ) { $found['webhook_id'] = $m[1]; } continue; } if ( 'response' === $section && preg_match( '/^ {20}\[([A-Za-z]+)\] => (.*)$/', $line, $m ) ) { if ( 'Code' === $m[1] && ! isset( $found['code'] ) ) { $found['code'] = $m[2]; } elseif ( 'Message' === $m[1] && ! isset( $found['message'] ) ) { $found['message'] = $m[2]; } elseif ( 'Headers' === $m[1] || 'Body' === $m[1] ) { break; // Code and Message come first; the rest is never read. } continue; } } $webhook_id = isset( $found['webhook_id'] ) && preg_match( '/^[1-9]\d{0,18}$/', $found['webhook_id'] ) ? (int) $found['webhook_id'] : 0; $code = isset( $found['code'] ) && preg_match( '/^[A-Za-z0-9_.\-]{1,64}$/', $found['code'] ) ? $found['code'] : ''; if ( ! $webhook_id || '' === $code ) { return null; } $delivery = isset( $found['delivery_id'] ) && preg_match( '/^[A-Za-z0-9_\-]{1,64}$/', $found['delivery_id'] ) ? $found['delivery_id'] : ''; $duration = isset( $found['duration'] ) && is_numeric( $found['duration'] ) && (float) $found['duration'] >= 0 && is_finite( (float) $found['duration'] ) ? (float) $found['duration'] : null; return array( 'delivery_id' => $delivery, 'webhook_id' => $webhook_id, 'duration' => $duration, 'code' => $code, 'message' => self::cut( trim( $found['message'] ?? '' ) ), 'raw_body' => false !== $raw_body, // No request Body key: treated as logged. ); } /** * Split text into lines on LF only, dropping trailing CRs (CRLF files). A bare CR inside a line * (it can come from a logged response header value) stays there, so it never starts a line. * * @since 1.0.0 * @param string $text Text. * @return string[] */ private static function lines( string $text ): array { $lines = explode( "\n", $text ); foreach ( $lines as $i => $line ) { $lines[ $i ] = rtrim( $line, "\r" ); } return $lines; } /** * Whether a logged response code counts as delivered, by WooCommerce's own rule * (`WC_Webhook::log_delivery()`: 2xx, 301 or 302). * * @since 1.0.0 * @param string $code Logged code (an HTTP status, or a WP_Error code when the request failed). * @return bool */ public static function is_success( string $code ): bool { return ctype_digit( $code ) && (int) $code >= 200 && (int) $code < 303; } /** * Cut text to MESSAGE_MAX characters (UTF-8 safe, invalid bytes dropped). * * @since 1.0.0 * @param string $text Text. * @return string */ public static function cut( string $text ): string { if ( function_exists( 'mb_substr' ) ) { $text = mb_convert_encoding( $text, 'UTF-8', 'UTF-8' ); $text = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text ); return mb_strlen( $text, 'UTF-8' ) > self::MESSAGE_MAX ? mb_substr( $text, 0, self::MESSAGE_MAX - 1, 'UTF-8' ) . '…' : $text; } $text = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $text ); return strlen( $text ) > self::MESSAGE_MAX ? substr( $text, 0, self::MESSAGE_MAX - 3 ) . '...' : $text; } }