'', 'enabled' => false, ); } /** * Saved settings. Only the public site key and enabled flag are returned. * * @return array{site_key: string, enabled: bool} */ public static function get_settings() { $stored = get_option( self::OPTION_NAME, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } $settings = array_merge( self::defaults(), $stored ); return array( 'site_key' => self::normalize_site_key( $settings['site_key'] ), 'enabled' => (bool) $settings['enabled'], ); } /** * Public tracking keys are cw_ plus 36 lowercase hex characters. * * @param mixed $key Raw key. * @return string */ public static function normalize_site_key( $key ) { $key = strtolower( trim( (string) $key ) ); return $key; } /** * Whether the value matches a CauseWise public tracking key. * * Management keys (cw_api_…) do not match. * * @param mixed $key Candidate key. * @return bool */ public static function is_valid_public_key( $key ) { return 1 === preg_match( '/^cw_[a-f0-9]{36}$/', self::normalize_site_key( $key ) ); } /** * Official embed shown in the CauseWise dashboard. * * @param string $site_key Public tracking key. * @return string */ public static function example_snippet( $site_key = 'cw_...' ) { return '<' . 'script async src="https://causewise.io/causewise.js" data-site="' . $site_key . '" data-collect-base="https://causewise.io">'; } /** * Resolve a posted key field or dashboard snippet to a public site key. * * A non-empty snippet wins. The snippet is parsed and then discarded. * Nothing from the snippet is executed or returned except a validated key. * * @param mixed $key_field Public key field. * @param mixed $snippet_field Snippet field. * @return array{key: string, error: string} */ public static function resolve_public_key( $key_field, $snippet_field ) { $snippet = trim( (string) $snippet_field ); $key = trim( (string) $key_field ); if ( '' !== $snippet ) { return self::parse_snippet( $snippet ); } if ( false !== stripos( $key, ' '', 'error' => '', ); } if ( 0 === strpos( $key, 'cw_api_' ) ) { return array( 'key' => '', 'error' => 'management', ); } if ( ! self::is_valid_public_key( $key ) ) { return array( 'key' => '', 'error' => 'invalid_key', ); } return array( 'key' => $key, 'error' => '', ); } /** * Extract data-site from one CauseWise script tag. * * @param string $raw Pasted snippet. * @return array{key: string, error: string} */ public static function parse_snippet( $raw ) { $raw = trim( (string) $raw ); if ( '' === $raw || strlen( $raw ) > 2000 || false !== strpos( $raw, "\0" ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } if ( 1 !== preg_match_all( '/ '', 'error' => 'invalid_snippet', ); } if ( ! preg_match( '/^\s*]*)>\s*<\/script>\s*$/is', $raw, $match ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } $attributes = self::parse_script_attributes( $match[1] ); if ( ! is_array( $attributes ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } $required = array( 'async', 'src', 'data-site', 'data-collect-base' ); foreach ( $required as $name ) { if ( ! isset( $attributes[ $name ] ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } } if ( count( $attributes ) !== count( $required ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } if ( 'https://causewise.io/causewise.js' !== $attributes['src'] ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } if ( 'https://causewise.io' !== rtrim( $attributes['data-collect-base'], '/' ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } $key = self::normalize_site_key( $attributes['data-site'] ); if ( 0 === strpos( $key, 'cw_api_' ) ) { return array( 'key' => '', 'error' => 'management', ); } if ( ! self::is_valid_public_key( $key ) ) { return array( 'key' => '', 'error' => 'invalid_snippet', ); } return array( 'key' => $key, 'error' => '', ); } /** * Parse the allowed CauseWise script attributes. Unknown attributes fail closed. * * @param string $source Attribute text inside the script tag. * @return array|null */ private static function parse_script_attributes( $source ) { $allowed = array( 'async' => true, 'src' => true, 'data-site' => true, 'data-collect-base' => true, ); $found = array(); $rest = (string) $source; while ( '' !== $rest ) { if ( preg_match( '/^\s+/', $rest, $space ) ) { $rest = (string) substr( $rest, strlen( $space[0] ) ); continue; } if ( ! preg_match( '/^([a-zA-Z_:][a-zA-Z0-9:._-]*)(?:\s*=\s*(?:"([^"]*)"|\'([^\']*)\'))?/', $rest, $attr ) ) { return null; } $name = strtolower( $attr[1] ); if ( ! isset( $allowed[ $name ] ) || isset( $found[ $name ] ) ) { return null; } $full = $attr[0]; if ( false === strpos( $full, '=' ) ) { if ( 'async' !== $name ) { return null; } $found[ $name ] = 'async'; } else { $value = ''; if ( preg_match( '/=\s*"([^"]*)"$/', $full, $quoted ) ) { $value = $quoted[1]; } elseif ( preg_match( "/=\s*'([^']*)'$/", $full, $quoted ) ) { $value = $quoted[1]; } else { return null; } $value = html_entity_decode( $value, ENT_QUOTES, 'UTF-8' ); if ( 'async' === $name && 'async' !== strtolower( $value ) ) { return null; } $found[ $name ] = $value; } $rest = (string) substr( $rest, strlen( $full ) ); } return $found; } /** * Add the settings page under Settings. */ public static function register_page() { add_options_page( __( 'CauseWise Analytics', 'causewise-analytics' ), __( 'CauseWise Analytics', 'causewise-analytics' ), self::CAPABILITY, self::PAGE_SLUG, array( __CLASS__, 'render_page' ) ); } /** * Register the option with the Settings API. */ public static function register_setting() { register_setting( self::OPTION_GROUP, self::OPTION_NAME, array( 'type' => 'array', 'sanitize_callback' => array( __CLASS__, 'sanitize' ), 'default' => self::defaults(), 'show_in_rest' => false, ) ); } /** * Sanitize a posted settings array. Invalid keys are not stored. * * @param mixed $input Posted value. * @return array{site_key: string, enabled: bool} */ public static function sanitize( $input ) { $previous = self::get_settings(); if ( ! current_user_can( self::CAPABILITY ) ) { return $previous; } if ( ! is_array( $input ) ) { return $previous; } $enabled = ! empty( $input['enabled'] ); $resolved = self::resolve_public_key( isset( $input['site_key'] ) ? wp_unslash( $input['site_key'] ) : '', isset( $input['snippet'] ) ? wp_unslash( $input['snippet'] ) : '' ); if ( '' !== $resolved['error'] ) { if ( 'management' === $resolved['error'] ) { add_settings_error( self::OPTION_NAME, 'causewise_management_key', __( 'That key is for showing analytics in WordPress, not for tracking. Enter the public site key from CauseWise.', 'causewise-analytics' ), 'error' ); } elseif ( 'invalid_snippet' === $resolved['error'] ) { add_settings_error( self::OPTION_NAME, 'causewise_invalid_snippet', __( 'That snippet cannot be used. Paste the tracking snippet from CauseWise, or enter the public site key.', 'causewise-analytics' ), 'error' ); } else { add_settings_error( self::OPTION_NAME, 'causewise_invalid_key', __( 'Enter the public site key from CauseWise.', 'causewise-analytics' ), 'error' ); } $previous['enabled'] = $enabled; return $previous; } return array( 'site_key' => $resolved['key'], 'enabled' => $enabled, ); } /** * Render the configuration screen. */ public static function render_page() { if ( ! current_user_can( self::CAPABILITY ) ) { wp_die( esc_html__( 'Sorry, you are not allowed to manage CauseWise Analytics.', 'causewise-analytics' ), 403 ); } $settings = self::get_settings(); $configured = self::is_valid_public_key( $settings['site_key'] ); $status = $configured ? __( 'Configured', 'causewise-analytics' ) : __( 'Not configured', 'causewise-analytics' ); ?>