api_key = null === $api_key ? useclick_get_api_key() : (string) $api_key; $this->base_url = self::base_url(); } /** * API base URL (filterable, for staging or local testing). * * @return string */ public static function base_url() { /** * Filters the UseClick.io API base URL. * * @param string $url Default https://useclick.io/api/v1 (or the USECLICK_API_BASE_URL constant). */ return untrailingslashit( (string) apply_filters( 'useclick_api_base_url', USECLICK_API_BASE_URL ) ); } /** * Whether a key is available. * * @return bool */ public function has_key() { return '' !== $this->api_key; } /** * Perform a request. * * @param string $endpoint Path relative to the base URL. * @param string $method HTTP method. * @param array $data JSON body (POST/PUT) or query args (GET). * @return array|WP_Error Decoded JSON or an error. */ private function request( $endpoint, $method = 'GET', $data = array() ) { if ( ! $this->has_key() ) { return new WP_Error( 'no_api_key', __( 'Add your UseClick.io API key in UseClick.io > Settings first.', 'useclick' ) ); } $url = $this->base_url . '/' . ltrim( $endpoint, '/' ); $args = array( 'method' => $method, // Reads fail fast so an admin screen never hangs on a slow connection; writes get longer. 'timeout' => 'GET' === $method ? 6 : 15, 'user-agent' => 'UseClick-WordPress/' . USECLICK_VERSION, 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, 'Accept' => 'application/json', ), ); if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( (object) $data ); } elseif ( ! empty( $data ) ) { $url = add_query_arg( rawurlencode_deep( $data ), $url ); } $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { return new WP_Error( 'http_request_failed', /* translators: %s: error message from the HTTP library */ sprintf( __( 'Could not reach UseClick.io: %s', 'useclick' ), $response->get_error_message() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status >= 400 ) { return $this->error_from_response( $status, $decoded, $response ); } if ( ! is_array( $decoded ) ) { return new WP_Error( 'invalid_response', __( 'UseClick.io returned an unexpected response. Please try again.', 'useclick' ) ); } return $decoded; } /** * Turn an error response into a WP_Error with a readable message. * * @param int $status HTTP status. * @param array|null $decoded Decoded body. * @param array $response Raw response. * @return WP_Error */ private function error_from_response( $status, $decoded, $response ) { $code = ''; $message = ''; if ( is_array( $decoded ) ) { if ( isset( $decoded['error'] ) && is_array( $decoded['error'] ) ) { $code = isset( $decoded['error']['code'] ) ? (string) $decoded['error']['code'] : ''; $message = isset( $decoded['error']['message'] ) ? (string) $decoded['error']['message'] : ''; } elseif ( isset( $decoded['error'] ) && is_string( $decoded['error'] ) ) { $message = $decoded['error']; $code = isset( $decoded['code'] ) ? (string) $decoded['code'] : ''; } } if ( '' === $code ) { $code = 'HTTP_' . $status; } $data = array( 'status' => $status, 'api_code' => $code, ); if ( 401 === $status ) { $message = __( 'UseClick.io did not accept the API key. Copy it again without spaces, and check under Account > API keys in your UseClick.io dashboard that it is still active.', 'useclick' ); } elseif ( 429 === $status ) { $limit = (int) self::header_value( $response, 'x-ratelimit-limit' ); $reset = (int) self::header_value( $response, 'x-ratelimit-reset' ); $wait = $reset > time() ? $reset - time() : 60; $data['retry_after'] = $wait; $message = $limit > 0 /* translators: 1: requests per minute, 2: seconds to wait */ ? sprintf( __( 'Rate limit reached (%1$d requests per minute on your plan). Try again in %2$d seconds.', 'useclick' ), $limit, $wait ) /* translators: %d: seconds to wait */ : sprintf( __( 'Rate limit reached. Try again in %d seconds.', 'useclick' ), $wait ); } elseif ( 'FEATURE_NOT_AVAILABLE' === $code || 'LINK_LIMIT_REACHED' === $code ) { $data['upgrade_url'] = USECLICK_APP_URL . '/pricing'; if ( is_array( $decoded ) && ! empty( $decoded['requiredPlan'] ) ) { $data['required_plan'] = sanitize_key( $decoded['requiredPlan'] ); } if ( is_array( $decoded ) && ! empty( $decoded['feature'] ) ) { $data['feature'] = sanitize_key( $decoded['feature'] ); } } if ( '' === $message ) { /* translators: %d: HTTP status code */ $message = sprintf( __( 'UseClick.io request failed (HTTP %d).', 'useclick' ), $status ); } return new WP_Error( $code, $message, $data ); } /** * A response header as a string (repeated headers come back as an array). * * @param array $response Raw response. * @param string $name Header name. * @return string */ private static function header_value( $response, $name ) { $value = wp_remote_retrieve_header( $response, $name ); if ( is_array( $value ) ) { $value = reset( $value ); } return (string) $value; } /** * Verify the key and return the account context (uncached). * * @return array|WP_Error { authenticated, user_id, organization_id, plan: {...}, rate_limit_per_minute } */ public function verify_key() { $result = $this->request( 'auth/verify' ); if ( is_wp_error( $result ) ) { return $result; } return isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : new WP_Error( 'invalid_response', __( 'UseClick.io returned an unexpected response. Please try again.', 'useclick' ) ); } /** * Account context, cached for five minutes per key. * * @param bool $force Skip the cache. * @return array|WP_Error */ public function get_account( $force = false ) { if ( ! $this->has_key() ) { return $this->request( 'auth/verify' ); } $cache_key = self::ACCOUNT_CACHE_PREFIX . md5( $this->api_key ); if ( ! $force ) { if ( isset( self::$memo[ $cache_key ] ) ) { return self::$memo[ $cache_key ]; } $cached = get_transient( $cache_key ); if ( is_array( $cached ) && isset( $cached['__error'] ) ) { // A recent failure: answer from the cache instead of waiting on the network again. return new WP_Error( (string) $cached['__error'], (string) $cached['message'], $cached['data'] ); } if ( is_array( $cached ) ) { return $cached; } } $account = $this->verify_key(); if ( is_wp_error( $account ) && 'http_request_failed' === $account->get_error_code() ) { // A slow or briefly unreachable API: keep showing the last known account (plan, // limits) instead of "not reachable" for a minute after one hiccup. $last = get_option( 'useclick_account_last_good' ); if ( is_array( $last ) && isset( $last['key'], $last['account'] ) && md5( $this->api_key ) === $last['key'] ) { self::$memo[ $cache_key ] = $last['account']; return $last['account']; } } if ( ! is_wp_error( $account ) ) { update_option( 'useclick_account_last_good', array( 'key' => md5( $this->api_key ), 'account' => $account, ), false ); } if ( is_wp_error( $account ) ) { set_transient( $cache_key, array( '__error' => $account->get_error_code(), 'message' => $account->get_error_message(), 'data' => $account->get_error_data(), ), MINUTE_IN_SECONDS ); } else { set_transient( $cache_key, $account, 5 * MINUTE_IN_SECONDS ); } // Remember errors too, so one screen never asks twice. self::$memo[ $cache_key ] = $account; return $account; } /** * Forget the cached account and click counts (after a key change or a new link). */ public static function flush_cache() { self::$memo = array(); $key = useclick_get_api_key(); if ( '' !== $key ) { delete_transient( self::ACCOUNT_CACHE_PREFIX . md5( $key ) ); } delete_transient( 'useclick_click_counts' ); delete_transient( 'useclick_slug_clicks' ); } /** * Create a short link. * * @param array $link Sanitized fields (see UseClick_Admin::sanitize_link_fields()). * @return array|WP_Error Created link including short_url. */ public function create_link( array $link ) { $result = $this->request( 'links', 'POST', $link ); if ( is_wp_error( $result ) ) { return $result; } return isset( $result['data'] ) ? $result['data'] : $result; } /** * List links (newest first). * * @param int $page Page number. * @param int $limit Page size (1-100). * @param string $campaign Optional campaign filter. * @return array|WP_Error { data: [...], pagination: { page, limit, total, pages } } */ public function get_links( $page = 1, $limit = 100, $campaign = '' ) { $params = array( 'page' => max( 1, (int) $page ), 'limit' => min( 100, max( 1, (int) $limit ) ), ); if ( '' !== $campaign ) { $params['campaign'] = $campaign; } return $this->request( 'links', 'GET', $params ); } /** * Update a link. Only the fields present are changed. * * @param string $slug Link slug. * @param array $fields Sanitized fields. * @return array|WP_Error */ public function update_link( $slug, array $fields ) { $result = $this->request( 'links/' . rawurlencode( $slug ), 'PUT', $fields ); if ( is_wp_error( $result ) ) { return $result; } return isset( $result['data'] ) ? $result['data'] : $result; } /** * Delete a link. * * @param string $slug Link slug. * @return array|WP_Error */ public function delete_link( $slug ) { return $this->request( 'links/' . rawurlencode( $slug ), 'DELETE' ); } /** * Short domains: the built-in one (id "default") plus active branded domains. * * @return array|WP_Error List of { id, domain, is_default }. */ public function get_domains() { $result = $this->request( 'domains' ); if ( is_wp_error( $result ) ) { return $result; } return isset( $result['domains'] ) && is_array( $result['domains'] ) ? $result['domains'] : array(); } /** * Website Analytics sites plus the plan's website allowance. * * @return array|WP_Error { data: [...], plan: { plan_name, max_websites } } */ public function get_websites() { return $this->request( 'websites' ); } /** * Free plan website analytics trial ({ phase, trial_ends_at, ... }) or null, cached 10 minutes. * * @return array|null */ public function get_trial() { $cache_key = 'useclick_trial_' . md5( $this->api_key ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return isset( $cached['trial'] ) && is_array( $cached['trial'] ) ? $cached['trial'] : null; } $result = $this->get_websites(); if ( is_wp_error( $result ) ) { return null; } $trial = isset( $result['trial'] ) && is_array( $result['trial'] ) ? $result['trial'] : null; set_transient( $cache_key, array( 'trial' => $trial ), 10 * MINUTE_IN_SECONDS ); return $trial; } /** * A website's report as numbers (visitors, page views, top pages, sources, countries, * devices, bots), drawn as native boxes on the Website analytics screen. Cached for 5 minutes. * * @param string $site_id Website id. * @param int $days 1, 7, 30 or 90. * @return array|WP_Error The report (the response's data object). */ public function get_website_stats( $site_id, $days = 30 ) { $days = in_array( (int) $days, array( 1, 7, 30, 90 ), true ) ? (int) $days : 30; $cache_key = 'useclick_stats_' . md5( $this->api_key . '|' . $site_id . '|' . $days ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $result = $this->request( 'websites/' . rawurlencode( $site_id ) . '/stats', 'GET', array( 'days' => $days ) ); if ( is_wp_error( $result ) ) { return $result; } if ( ! isset( $result['data'] ) || ! is_array( $result['data'] ) ) { return new WP_Error( 'invalid_response', __( 'UseClick.io returned an unexpected response. Please try again.', 'useclick' ) ); } set_transient( $cache_key, $result['data'], 5 * MINUTE_IN_SECONDS ); return $result['data']; } /** * Add a Website Analytics site (or get the existing one for the same domain). * * @param string $domain Domain, e.g. example.com. * @param string $name Display name. * @return array|WP_Error { data: { id, domain, name }, created: bool } */ public function create_website( $domain, $name = '' ) { return $this->request( 'websites', 'POST', array( 'domain' => $domain, 'name' => $name, ) ); } /** * Map of slug => total clicks for the 100 most recent links, cached for five minutes. * * @return array */ public function get_click_counts() { $cached = get_transient( 'useclick_click_counts' ); if ( is_array( $cached ) ) { return $cached; } $counts = array(); $result = $this->get_links( 1, 100 ); if ( is_wp_error( $result ) ) { // Retry after a minute rather than calling the API on every screen load. set_transient( 'useclick_click_counts', $counts, MINUTE_IN_SECONDS ); return $counts; } if ( ! empty( $result['data'] ) && is_array( $result['data'] ) ) { foreach ( $result['data'] as $link ) { if ( isset( $link['slug'] ) ) { $counts[ (string) $link['slug'] ] = isset( $link['total_clicks'] ) ? (int) $link['total_clicks'] : 0; } } } set_transient( 'useclick_click_counts', $counts, 5 * MINUTE_IN_SECONDS ); return $counts; } /** * Click totals for specific slugs: the 100 newest links come from get_click_counts(); older * ones are looked up one by one (at most 20 per call) and cached for five minutes. * * @param string[] $slugs Slugs. * @return array slug => clicks (missing when unknown). */ public function get_clicks_for_slugs( array $slugs ) { $counts = $this->get_click_counts(); $extra = get_transient( 'useclick_slug_clicks' ); $extra = is_array( $extra ) ? $extra : array(); $changed = false; $lookups = 0; $out = array(); foreach ( array_unique( array_filter( array_map( 'strval', $slugs ) ) ) as $slug ) { if ( isset( $counts[ $slug ] ) ) { $out[ $slug ] = $counts[ $slug ]; continue; } if ( array_key_exists( $slug, $extra ) ) { if ( null !== $extra[ $slug ] ) { $out[ $slug ] = (int) $extra[ $slug ]; } continue; } if ( $lookups >= 20 || ! preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $slug ) ) { continue; } ++$lookups; $result = $this->request( 'links/' . rawurlencode( $slug ) ); $link = ! is_wp_error( $result ) && isset( $result['data'] ) && is_array( $result['data'] ) ? $result['data'] : null; $extra[ $slug ] = $link ? (int) ( isset( $link['total_clicks'] ) ? $link['total_clicks'] : ( isset( $link['click_count'] ) ? $link['click_count'] : 0 ) ) : null; $changed = true; if ( null !== $extra[ $slug ] ) { $out[ $slug ] = $extra[ $slug ]; } } if ( $changed ) { set_transient( 'useclick_slug_clicks', $extra, 5 * MINUTE_IN_SECONDS ); } return $out; } }