options = $options; } /** * Register hooks (currently none beyond settings save; kept for symmetry). */ public function register() { } /** * Save BYO settings from the settings form. * * @param bool $enabled Whether BYO mode is enabled. * @param string $url Umami base URL. * @param string $website_id Umami website ID. * @return true|WP_Error */ public function save( $enabled, $url, $website_id ) { $url = $this->normalize_url( $url ); $website_id = sanitize_text_field( (string) $website_id ); if ( ! $enabled ) { $this->options->update_byo( array( 'enabled' => false, 'url' => $url, 'website_id' => $website_id, ) ); if ( 'byo' === $this->options->get_connection()['mode'] ) { $this->options->reset_connection(); } return true; } if ( is_wp_error( $url ) ) { return $url; } if ( '' === $website_id ) { return new WP_Error( 'ossengine_byo_invalid', __( 'Enter the Website ID from your Umami dashboard (Settings → Websites).', 'ossengine-analytics-connector' ) ); } $check = $this->validate_website( $url, $website_id ); if ( is_wp_error( $check ) ) { return $check; } $this->options->update_byo( array( 'enabled' => true, 'url' => $url, 'website_id' => $website_id, ) ); // Mutually exclusive: BYO replaces any managed connection. $this->options->update_connection( array( 'mode' => 'byo', 'status' => 'connected', 'site_url' => home_url(), 'ossengine_website_id' => $website_id, 'script_url' => $url . '/script.js', 'status_message' => '', ) ); $this->options->clear_site_token(); return true; } /** * Active BYO tracking config for the Tracking module. * * @return array|null Array with script_url + website_id. */ public function active_config() { $byo = $this->options->get_byo(); $connection = $this->options->get_connection(); if ( empty( $byo['enabled'] ) || 'byo' !== $connection['mode'] ) { return null; } if ( '' === $connection['script_url'] || '' === $connection['ossengine_website_id'] ) { return null; } return array( 'script_url' => $connection['script_url'], 'website_id' => $connection['ossengine_website_id'], ); } /** * Validate that a website exists on the given Umami instance. * * The Umami API (GET /api/websites/{id}) requires authentication on most * instances, so an anonymous probe commonly returns 401/403 even when the * instance and website ID are correct. In that case we fall back to * verifying the public tracker script, which proves the base URL is a * working Umami host, and accept the website ID as entered. A clearly * wrong ID can only be caught when the API answers anonymously. * * @param string $url Normalized Umami base URL. * @param string $website_id Website ID. * @return true|WP_Error */ public function validate_website( $url, $website_id ) { $endpoint = $url . '/api/websites/' . rawurlencode( $website_id ); $response = wp_safe_remote_get( $endpoint, array( 'timeout' => self::TIMEOUT_SECONDS, 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( is_wp_error( $response ) ) { ossengine_log( 'BYO Umami test connection failed.' ); return new WP_Error( 'ossengine_byo_unreachable', __( 'Could not reach your Umami instance. Check the Umami URL and try again.', 'ossengine-analytics-connector' ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 404 === $code ) { return new WP_Error( 'ossengine_byo_not_found', __( 'That Website ID was not found on your Umami instance. Check the ID and try again.', 'ossengine-analytics-connector' ) ); } if ( $code >= 200 && $code < 300 ) { $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( $this->response_matches_website( $data, $website_id ) ) { return true; } if ( $this->response_has_website_id( $data ) ) { // The API answered with a different website — definitive ID // mismatch, not a version quirk. return new WP_Error( 'ossengine_byo_invalid', __( 'That Website ID does not match your Umami instance. Check the ID and try again.', 'ossengine-analytics-connector' ) ); } // Tolerate version differences in the response envelope: if the // API answered 2xx but with an unfamiliar shape, fall back to the // tracker-script check instead of hard-failing. if ( $this->tracker_script_reachable( $url ) ) { return true; } return new WP_Error( 'ossengine_byo_invalid', __( 'Your Umami instance returned an unexpected response. Check the URL and website ID.', 'ossengine-analytics-connector' ) ); } if ( 401 === $code || 403 === $code ) { // Expected for Umami instances that require auth: prove the host // is Umami via its public tracker script. if ( $this->tracker_script_reachable( $url ) ) { ossengine_log( 'BYO Umami API requires auth; tracker script confirmed.' ); return true; } return new WP_Error( 'ossengine_byo_auth', __( 'Your Umami instance asked for authentication and its tracker script could not be verified. Check the Umami URL and make sure it is publicly reachable.', 'ossengine-analytics-connector' ) ); } // Other statuses (5xx, redirects-as-errors, WAF blocks, ...): if the // tracker script loads, the host is Umami and the API probe result // should not block setup. if ( $this->tracker_script_reachable( $url ) ) { return true; } return new WP_Error( 'ossengine_byo_invalid', __( 'Your Umami instance returned an unexpected response. Check the URL and try again.', 'ossengine-analytics-connector' ) ); } /** * Check whether a decoded API response identifies the expected website. * * Handles current ({id}) and wrapped ({data:{id}}, {website:{id}}) * envelopes across Umami versions. * * @param mixed $data Decoded JSON body. * @param string $website_id Expected website ID. * @return bool */ private function response_matches_website( $data, $website_id ) { if ( ! is_array( $data ) ) { return false; } $candidates = array(); if ( isset( $data['id'] ) ) { $candidates[] = $data['id']; } if ( isset( $data['websiteId'] ) ) { $candidates[] = $data['websiteId']; } if ( isset( $data['data'] ) && is_array( $data['data'] ) ) { if ( isset( $data['data']['id'] ) ) { $candidates[] = $data['data']['id']; } if ( isset( $data['data']['websiteId'] ) ) { $candidates[] = $data['data']['websiteId']; } } if ( isset( $data['website'] ) && is_array( $data['website'] ) && isset( $data['website']['id'] ) ) { $candidates[] = $data['website']['id']; } foreach ( $candidates as $candidate ) { if ( (string) $candidate === (string) $website_id ) { return true; } } return false; } /** * Check whether a decoded API response carries any website identifier. * * Used to distinguish a definitive ID mismatch (response names a * different website) from an unfamiliar envelope (no id field at all, * where the tracker-script fallback applies). * * @param mixed $data Decoded JSON body. * @return bool */ private function response_has_website_id( $data ) { if ( ! is_array( $data ) ) { return false; } if ( isset( $data['id'] ) || isset( $data['websiteId'] ) ) { return true; } if ( isset( $data['data'] ) && is_array( $data['data'] ) && ( isset( $data['data']['id'] ) || isset( $data['data']['websiteId'] ) ) ) { return true; } if ( isset( $data['website'] ) && is_array( $data['website'] ) && isset( $data['website']['id'] ) ) { return true; } return false; } /** * Verify the public tracker script loads (proves the host runs Umami). * * @param string $url Normalized Umami base URL. * @return bool */ private function tracker_script_reachable( $url ) { $response = wp_safe_remote_get( $url . '/script.js', array( 'timeout' => self::TIMEOUT_SECONDS, 'headers' => array( 'Accept' => 'application/javascript, */*;q=0.8' ), ) ); if ( is_wp_error( $response ) ) { return false; } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { return false; } $body = trim( (string) wp_remote_retrieve_body( $response ) ); return '' !== $body; } /** * Normalize + SSRF-screen a user-supplied Umami base URL. * * Allows private-LAN hosts (self-hosting commonly lives there — the admin * configures this URL explicitly) but rejects loopback/localhost targets * that would turn the test-connection into a server-self request, plus * non-HTTP schemes and embedded credentials. * * @param string $raw Raw URL. * @return string|WP_Error Normalized base URL or error. */ public function normalize_url( $raw ) { $raw = trim( (string) $raw ); if ( '' === $raw ) { return new WP_Error( 'ossengine_byo_invalid', __( 'Enter your Umami base URL, e.g. https://umami.example.com.', 'ossengine-analytics-connector' ) ); } $parts = wp_parse_url( $raw ); if ( ! is_array( $parts ) || empty( $parts['host'] ) ) { return new WP_Error( 'ossengine_byo_invalid', __( 'That Umami URL is not valid. Use the base URL of your Umami installation.', 'ossengine-analytics-connector' ) ); } $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : ''; if ( 'http' !== $scheme && 'https' !== $scheme ) { return new WP_Error( 'ossengine_byo_invalid', __( 'The Umami URL must start with http:// or https://.', 'ossengine-analytics-connector' ) ); } if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return new WP_Error( 'ossengine_byo_invalid', __( 'The Umami URL must not contain credentials.', 'ossengine-analytics-connector' ) ); } $host = strtolower( $parts['host'] ); if ( 'localhost' === $host || substr( $host, -10 ) === '.localhost' || '127.0.0.1' === $host || '::1' === $host || '[::1]' === $host ) { return new WP_Error( 'ossengine_byo_invalid', __( 'The Umami URL must point to a reachable server, not this machine.', 'ossengine-analytics-connector' ) ); } $port = isset( $parts['port'] ) ? ':' . (int) $parts['port'] : ''; // Preserve a subpath: self-hosted Umami often lives under one // (e.g. https://example.com/umami). A trailing slash is trimmed so // endpoint building ($base . '/api/...') stays correct. $path = isset( $parts['path'] ) ? rtrim( (string) $parts['path'], '/' ) : ''; if ( '' !== $path && '/' !== $path[0] ) { $path = '/' . $path; } if ( '/' === $path ) { $path = ''; } return $scheme . '://' . $host . $port . $path; } }