$args Block type arguments. * @param string $name Block name. * @return array */ public static function register_block_type_args( $args, $name ) { if ( ! self::supports_block( $name ) || ! is_array( $args ) ) { return $args; } if ( ! isset( $args['attributes'] ) || ! is_array( $args['attributes'] ) ) { $args['attributes'] = array(); } $args['attributes']['causewiseTrack'] = array( 'type' => 'boolean', 'default' => false, ); $args['attributes']['causewiseEvent'] = array( 'type' => 'string', 'default' => '', ); $args['attributes']['causewiseProps'] = array( 'type' => 'array', 'default' => array(), 'items' => array( 'type' => 'object', 'properties' => array( 'key' => array( 'type' => 'string' ), 'value' => array( 'type' => 'string' ), ), ), ); return $args; } /** * Values for the editor script. No site key and no management key. * * @return array{trackingEnabled: bool, settingsUrl: string} */ public static function editor_config() { return array( 'trackingEnabled' => Causewise_Tracker::should_load( Causewise_Settings::get_settings(), false ), 'settingsUrl' => admin_url( 'options-general.php?page=' . Causewise_Settings::PAGE_SLUG ), ); } /** * Inspector script and styles. The block editor does not load the public tracker. */ public static function enqueue_editor_assets() { if ( self::$editor_enqueued ) { return; } self::$editor_enqueued = true; $version = defined( 'CAUSEWISE_ANALYTICS_VERSION' ) ? CAUSEWISE_ANALYTICS_VERSION : '0.4.6'; $file = defined( 'CAUSEWISE_ANALYTICS_FILE' ) ? CAUSEWISE_ANALYTICS_FILE : dirname( __DIR__ ) . '/causewise-analytics.php'; wp_enqueue_style( 'causewise-block-editor', plugins_url( 'assets/block-editor.css', $file ), array(), $version ); wp_enqueue_script( 'causewise-block-editor', plugins_url( 'assets/block-editor.js', $file ), array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-compose', 'wp-hooks', 'wp-i18n', ), $version, true ); wp_localize_script( 'causewise-block-editor', 'causewiseBlockEditor', self::editor_config() ); } /** * Click listener for public pages that already load causewise.js. */ public static function enqueue_front() { if ( self::$front_enqueued ) { return; } if ( function_exists( 'is_customize_preview' ) && is_customize_preview() ) { return; } if ( ! Causewise_Tracker::should_load( Causewise_Settings::get_settings(), is_admin() ) ) { return; } self::$front_enqueued = true; $version = defined( 'CAUSEWISE_ANALYTICS_VERSION' ) ? CAUSEWISE_ANALYTICS_VERSION : '0.4.6'; $file = defined( 'CAUSEWISE_ANALYTICS_FILE' ) ? CAUSEWISE_ANALYTICS_FILE : dirname( __DIR__ ) . '/causewise-analytics.php'; wp_enqueue_script( 'causewise-block-front', plugins_url( 'assets/block-front.js', $file ), array(), $version, true ); } /** * Whether the block attribute opted in. * * @param array $attrs Block attributes. * @return bool */ public static function is_enabled( $attrs ) { if ( ! is_array( $attrs ) || ! isset( $attrs['causewiseTrack'] ) ) { return false; } $value = $attrs['causewiseTrack']; return true === $value || 1 === $value || '1' === $value || 'true' === $value; } /** * Event names match CauseWise custom events. pageview is reserved for the tracker. * * @param mixed $input Raw attribute. * @return string */ public static function sanitize_event_name( $input ) { $raw = strtolower( trim( (string) $input ) ); if ( '' === $raw || strlen( $raw ) > 64 || 'pageview' === $raw ) { return ''; } if ( ! preg_match( '/^[a-z][a-z0-9_]{0,63}$/', $raw ) ) { return ''; } return $raw; } /** * Safe labels typed in the editor. Form values are never available here. * * @param mixed $input Attribute rows or a plain object. * @return array */ public static function sanitize_properties( $input ) { $props = array(); foreach ( self::property_rows( $input ) as $row ) { if ( count( $props ) >= self::MAX_PROPERTIES ) { break; } $key = isset( $row['key'] ) ? trim( (string) $row['key'] ) : ''; $value = isset( $row['value'] ) ? trim( (string) $row['value'] ) : ''; if ( '' === $key || '' === $value ) { continue; } if ( ! preg_match( '/^[A-Za-z][A-Za-z0-9_]{0,63}$/', $key ) || self::is_denied_key( $key ) ) { continue; } if ( isset( $props[ $key ] ) ) { continue; } $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value, 'UTF-8' ) : strlen( $value ); if ( $length > self::MAX_VALUE || preg_match( '/[\x00-\x1F]/', $value ) || self::is_sensitive_value( $value ) ) { continue; } $props[ $key ] = $value; } return $props; } /** * Payload written onto the rendered block, or null when nothing should be sent. * * @param mixed $attrs Block attributes. * @return array{event: string, props: array}|null */ public static function tracking_payload( $attrs ) { if ( ! self::is_enabled( $attrs ) ) { return null; } $event = self::sanitize_event_name( isset( $attrs['causewiseEvent'] ) ? $attrs['causewiseEvent'] : '' ); if ( '' === $event ) { return null; } return array( 'event' => $event, 'props' => self::sanitize_properties( isset( $attrs['causewiseProps'] ) ? $attrs['causewiseProps'] : array() ), ); } /** * Add or remove tracking attributes without changing the block's other markup. * * @param string $content Rendered block HTML. * @param array $block Parsed block. * @return string */ public static function render_block( $content, $block ) { if ( ! is_string( $content ) ) { return $content; } $name = is_array( $block ) && isset( $block['blockName'] ) ? $block['blockName'] : ''; if ( ! self::supports_block( $name ) ) { return $content; } $attrs = is_array( $block ) && isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array(); $payload = self::tracking_payload( $attrs ); if ( null === $payload ) { return self::strip_tracking_attributes( $content ); } return self::add_root_attributes( $content, $payload['event'], $payload['props'] ); } /** * @param string $html Block HTML. * @param string $event Valid event name. * @param array $props Safe properties. * @return string */ public static function add_root_attributes( $html, $event, $props ) { if ( class_exists( 'WP_HTML_Tag_Processor' ) ) { $processor = new WP_HTML_Tag_Processor( $html ); if ( ! $processor->next_tag() ) { return $html; } $processor->set_attribute( 'data-causewise-event', $event ); if ( ! empty( $props ) ) { $processor->set_attribute( 'data-causewise-props', wp_json_encode( $props ) ); } else { $processor->remove_attribute( 'data-causewise-props' ); } $updated = $processor->get_updated_html(); return is_string( $updated ) ? $updated : $html; } return self::add_root_attributes_fallback( $html, $event, $props ); } /** * @param string $html Block HTML. * @return string */ private static function strip_tracking_attributes( $html ) { if ( false === strpos( $html, 'data-causewise-' ) ) { return $html; } if ( class_exists( 'WP_HTML_Tag_Processor' ) ) { $processor = new WP_HTML_Tag_Processor( $html ); if ( ! $processor->next_tag() ) { return $html; } $processor->remove_attribute( 'data-causewise-event' ); $processor->remove_attribute( 'data-causewise-props' ); $updated = $processor->get_updated_html(); return is_string( $updated ) ? $updated : $html; } $tag = self::first_tag( $html ); if ( null === $tag ) { return $html; } $updated = self::remove_attr( self::remove_attr( $tag['tag'], 'data-causewise-event' ), 'data-causewise-props' ); return substr( $html, 0, $tag['start'] ) . $updated . substr( $html, $tag['start'] + strlen( $tag['tag'] ) ); } /** * @param string $html Block HTML. * @param string $event Event name. * @param array $props Properties. * @return string */ private static function add_root_attributes_fallback( $html, $event, $props ) { $tag = self::first_tag( $html ); if ( null === $tag ) { return $html; } $updated = self::upsert_attr( $tag['tag'], 'data-causewise-event', $event ); if ( ! empty( $props ) ) { $updated = self::upsert_attr( $updated, 'data-causewise-props', wp_json_encode( $props ) ); } else { $updated = self::remove_attr( $updated, 'data-causewise-props' ); } return substr( $html, 0, $tag['start'] ) . $updated . substr( $html, $tag['start'] + strlen( $tag['tag'] ) ); } /** * @param string $html HTML. * @return array{tag: string, start: int}|null */ private static function first_tag( $html ) { if ( ! preg_match( '/<[a-zA-Z][^>]*>/', $html, $matches, PREG_OFFSET_CAPTURE ) ) { return null; } return array( 'tag' => $matches[0][0], 'start' => $matches[0][1], ); } /** * @param string $tag Opening tag. * @param string $name Attribute name. * @param string $value Attribute value. * @return string */ private static function upsert_attr( $tag, $name, $value ) { $attr = $name . '="' . esc_attr( $value ) . '"'; $pattern = '/\s' . preg_quote( $name, '/' ) . '\s*=\s*(?:"[^"]*"|\'[^\']*\')/'; if ( preg_match( $pattern, $tag ) ) { $replaced = preg_replace( $pattern, ' ' . $attr, $tag, 1 ); return is_string( $replaced ) ? $replaced : $tag; } if ( substr( $tag, -2 ) === '/>' ) { return substr( $tag, 0, -2 ) . ' ' . $attr . ' />'; } return substr( $tag, 0, -1 ) . ' ' . $attr . '>'; } /** * @param string $tag Opening tag. * @param string $name Attribute name. * @return string */ private static function remove_attr( $tag, $name ) { $pattern = '/\s' . preg_quote( $name, '/' ) . '\s*=\s*(?:"[^"]*"|\'[^\']*\')/'; $removed = preg_replace( $pattern, '', $tag, 1 ); return is_string( $removed ) ? $removed : $tag; } /** * @param mixed $input Rows or a string map. * @return array */ private static function property_rows( $input ) { if ( ! is_array( $input ) ) { return array(); } $rows = array(); if ( self::is_list_array( $input ) ) { foreach ( $input as $item ) { if ( ! is_array( $item ) ) { continue; } $rows[] = array( 'key' => isset( $item['key'] ) ? (string) $item['key'] : '', 'value' => self::scalar_string( isset( $item['value'] ) ? $item['value'] : '' ), ); } return $rows; } foreach ( $input as $key => $value ) { $scalar = self::scalar_string( $value ); if ( '' === $scalar && ! is_string( $value ) && ! is_numeric( $value ) && ! is_bool( $value ) ) { continue; } $rows[] = array( 'key' => (string) $key, 'value' => $scalar, ); } return $rows; } /** * @param mixed $value Property value. * @return string */ private static function scalar_string( $value ) { if ( is_string( $value ) ) { return $value; } if ( is_int( $value ) || is_float( $value ) ) { return (string) $value; } if ( true === $value ) { return 'true'; } if ( false === $value ) { return 'false'; } return ''; } /** * @param array $array Array. * @return bool */ private static function is_list_array( $array ) { $index = 0; foreach ( $array as $key => $_value ) { if ( $key !== $index ) { return false; } $index++; } return true; } /** * @param string $key Property name. * @return bool */ private static function is_denied_key( $key ) { $normalized = strtolower( $key ); $exact = array( 'email', 'e_mail', 'mail', 'name', 'full_name', 'first_name', 'last_name', 'firstname', 'lastname', 'fullname', 'password', 'passwd', 'secret', 'token', 'api_key', 'apikey', 'session', 'session_id', 'cookie', 'authorization', 'auth', 'phone', 'address', 'ssn', 'credit_card', 'card', 'cvv', 'cvc', 'message', 'body', 'query', 'user', 'user_id', 'username', 'ip', 'ip_address', 'constructor', 'prototype', 'proto', ); if ( in_array( $normalized, $exact, true ) ) { return true; } return 1 === preg_match( '/password|passwd|secret|token|email|ssn|cvv|cvc|credit|api_key|apikey/', $normalized ); } /** * @param string $value Property value. * @return bool */ private static function is_sensitive_value( $value ) { if ( false !== strpos( $value, '@' ) || false !== strpos( $value, '<' ) || false !== strpos( $value, '>' ) ) { return true; } $digits = preg_replace( '/[\s-]/', '', $value ); if ( is_string( $digits ) && preg_match( '/^\d{13,19}$/', $digits ) ) { return true; } if ( preg_match( '/cw_api_/i', $value ) || preg_match( '/cw_[0-9a-f]{36}/i', $value ) ) { return true; } if ( preg_match( '/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/', $value ) ) { return true; } if ( preg_match( '/[?&](?:access_token|id_token|token|password|passwd|secret|api_key)=/i', $value ) ) { return true; } return false; } }