options = $options; $this->api = $api; } /** * Register admin-post handlers. */ public function register() { add_action( 'admin_post_' . self::ACTION_CONNECT, array( $this, 'handle_connect' ) ); add_action( 'admin_post_' . self::ACTION_CALLBACK, array( $this, 'handle_callback' ) ); // The connect flow redirects the browser to the external dashboard // approve screen. wp_safe_redirect() only allows same-host redirects // by default, so whitelist the dashboard host. Without this WP falls // back to wp-admin and the button appears to "just redirect". add_filter( 'allowed_redirect_hosts', array( $this, 'allowed_redirect_hosts' ) ); } /** * Whitelist the dashboard host for the external connect redirect. * * @param array $hosts Allowed hosts. * @return array */ public function allowed_redirect_hosts( $hosts ) { $host = wp_parse_url( $this->options->dashboard_url(), PHP_URL_HOST ); if ( is_string( $host ) && '' !== $host && ! in_array( $host, (array) $hosts, true ) ) { $hosts[] = $host; } return $hosts; } /** * Step 1: generate CSRF state and redirect to the dashboard approve screen. */ public function handle_connect() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage analytics settings.', 'ossengine-analytics-connector' ), 403 ); } check_admin_referer( self::ACTION_CONNECT ); $state = wp_generate_password( 32, false ); set_transient( $this->state_key( $state ), home_url(), self::STATE_TTL ); $return_url = add_query_arg( array( 'action' => self::ACTION_CALLBACK ), admin_url( 'admin-post.php' ) ); $approve_url = add_query_arg( array( 'site_url' => home_url(), 'state' => $state, // Do NOT pre-encode: add_query_arg encodes once, and the // dashboard reads it via URLSearchParams (decodes once). 'return' => $return_url, ), // The approve screen lives on integrations.html, which reads // site_url/state/return from the query string (see // dashboard/js/integrations.js approveParams()). $this->options->dashboard_url() . '/integrations.html' ); wp_safe_redirect( $approve_url ); exit; } /** * Step 2: verify state, exchange the code, register the website, cache config. * * CSRF protection: this is an external OAuth-style redirect from the * UmamiEngine dashboard, so no WP nonce is available. The single-use * `state` transient (created in handle_connect, consumed here) is the * CSRF gate — unknown/expired state aborts before any HTTP or state * change. Read-only $_GET['code']/['state'] are sanitized below. */ public function handle_callback() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage analytics settings.', 'ossengine-analytics-connector' ), 403 ); } $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $expected_return = get_transient( $this->state_key( $state ) ); delete_transient( $this->state_key( $state ) ); if ( '' === $code || '' === $state || false === $expected_return ) { $this->fail( __( 'The connection request expired or is invalid. Please try connecting again.', 'ossengine-analytics-connector' ) ); } $site_url = home_url(); $complete = $this->api->complete( $code, $site_url, $state ); if ( is_wp_error( $complete ) ) { $this->fail( $complete->get_error_message() ); } // The backend answers with camelCase keys (integrationId, siteToken); // snake_case is accepted as a fallback for forward compatibility. $site_token = isset( $complete['siteToken'] ) ? $complete['siteToken'] : ( isset( $complete['site_token'] ) ? $complete['site_token'] : '' ); $integration_id_raw = isset( $complete['integrationId'] ) ? $complete['integrationId'] : ( isset( $complete['integration_id'] ) ? $complete['integration_id'] : '' ); if ( '' === (string) $site_token || '' === (string) $integration_id_raw ) { $this->fail( __( 'Unable to connect to UmamiEngine. Please try again.', 'ossengine-analytics-connector' ) ); } $integration_id = sanitize_text_field( (string) $integration_id_raw ); $this->options->set_site_token( (string) $site_token ); $this->options->update_connection( array( 'mode' => 'managed', 'status' => 'pending', 'integration_id' => $integration_id, 'site_url' => $site_url, ) ); $registered = $this->api->register_website( $site_token, $integration_id, $this->site_display_name( $site_url ) ); if ( is_wp_error( $registered ) ) { // Leave the connection in pending and persist the reason, so the // settings page (and dashboard) can explain it instead of // showing a bare "pending" with no path forward. $this->options->update_connection( array( 'status' => 'pending', 'status_message' => $registered->get_error_message(), ) ); $this->fail( $registered->get_error_message() ); } $connected = $this->refresh_config( $site_token, $integration_id ); if ( $connected ) { $this->options->set_notice( 'updated', __( 'Connected! Analytics tracking is now enabled.', 'ossengine-analytics-connector' ) ); } else { // Registration succeeded but tracking is not usable yet (e.g. // inactive subscription). Do not claim success — the settings // page shows the pending reason from status_message. $message = $this->options->get_connection()['status_message']; $this->options->set_notice( 'error', '' !== $message ? $message : __( 'The connection is pending. Check the status below.', 'ossengine-analytics-connector' ) ); } wp_safe_redirect( $this->settings_url() ); exit; } /** * Refresh cached tracking config from the backend. * * If website registration never completed (no Umami website ID cached — * e.g. the callback's register call hit a transient backend failure), * registration is retried first so a pending connection can heal via * "Refresh status", the weekly cron, or the next callback instead of * staying pending forever. * * @param string $token Site token. * @param string $integration_id Integration UUID. * @return bool True when tracking config is usable. */ public function refresh_config( $token, $integration_id ) { if ( '' === $this->options->get_connection()['ossengine_website_id'] ) { $retry = $this->api->register_website( $token, $integration_id, $this->site_display_name( home_url() ) ); if ( is_wp_error( $retry ) ) { if ( 'ossengine_expired' === $retry->get_error_code() || 'ossengine_not_found' === $retry->get_error_code() ) { $this->options->update_connection( array( 'status' => 'expired', 'status_message' => $retry->get_error_message(), ) ); } else { $this->options->update_connection( array( 'status' => 'pending', 'status_message' => $retry->get_error_message(), ) ); } return false; } } $config = $this->api->get_config( $token, $integration_id ); if ( is_wp_error( $config ) ) { if ( 'ossengine_expired' === $config->get_error_code() || 'ossengine_not_found' === $config->get_error_code() ) { $this->options->update_connection( array( 'status' => 'expired', 'status_message' => $config->get_error_message(), ) ); } else { $this->options->update_connection( array( 'status_message' => $config->get_error_message() ) ); } return false; } if ( empty( $config['connected'] ) ) { $this->options->update_connection( array( 'status' => 'pending', 'status_message' => isset( $config['message'] ) ? $config['message'] : '', 'account_url' => isset( $config['accountUrl'] ) ? $config['accountUrl'] : '', ) ); return false; } $this->options->update_connection( array( 'status' => 'connected', 'ossengine_website_id' => isset( $config['websiteId'] ) ? sanitize_text_field( $config['websiteId'] ) : '', 'script_url' => isset( $config['scriptUrl'] ) ? esc_url_raw( $config['scriptUrl'] ) : '', 'status_message' => '', ) ); return true; } /** * Redirect back to settings with a failure notice. * * @param string $message Admin-safe message. */ private function fail( $message ) { $this->options->set_notice( 'error', $message ); wp_safe_redirect( $this->settings_url() ); exit; } /** * Human-readable site name for the Umami website (site title, host fallback). * * @param string $site_url Site URL (fallback source). * @return string */ private function site_display_name( $site_url ) { $name = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : ''; $name = sanitize_text_field( $name ); if ( '' === $name ) { $name = (string) wp_parse_url( $site_url, PHP_URL_HOST ); } if ( function_exists( 'mb_substr' ) ) { return mb_substr( $name, 0, 255 ); } return substr( $name, 0, 255 ); } /** * Transient key for a CSRF state value. * * @param string $state State value. * @return string */ private function state_key( $state ) { return 'ossengine_state_' . md5( (string) $state ); } /** * Settings page URL. * * @return string */ private function settings_url() { return add_query_arg( array( 'page' => 'ossengine-analytics-connector' ), admin_url( 'options-general.php' ) ); } }