$method, 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . self::api_key(), 'Accept' => 'application/json', ), ); if ( isset( $args['body'] ) ) { $request['headers']['Content-Type'] = 'application/json'; $request['body'] = wp_json_encode( $args['body'] ); } $response = wp_remote_request( $url, $request ); if ( is_wp_error( $response ) ) { return $response; } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 ) { $detail = is_array( $body ) && isset( $body['detail'] ) ? $body['detail'] : sprintf( /* translators: %d: HTTP status code. */ __( 'Request failed (HTTP %d).', 'affkeeper' ), $code ); return new WP_Error( 'affkeeper_http_' . $code, is_string( $detail ) ? $detail : __( 'Request failed.', 'affkeeper' ), array( 'status' => $code ) ); } return is_array( $body ) ? $body : array(); } /** * GET /me — used by the settings page to confirm a key works. * * @return array|WP_Error */ public static function me() { return self::request( 'GET', '/me' ); } /** * Cached /me — for screens that only need the plan numbers (monitored X of Y) and shouldn't * add a live API call to every page load. 5-minute transient, busted with the rest. * * @return array|WP_Error */ public static function me_cached() { $cached = self::cache_get( 'me' ); if ( false !== $cached ) { return $cached; } $me = self::me(); if ( ! is_wp_error( $me ) ) { self::cache_set( 'me', $me ); } return $me; } /** * GET /links (paginated). Cached per page. * * @param int $page Page number. * @param int $per_page Page size (max 100). * @return array|WP_Error */ public static function links( $page = 1, $per_page = 50 ) { $page = max( 1, (int) $page ); $per_page = min( 100, max( 1, (int) $per_page ) ); $key = 'links_' . $page . '_' . $per_page; $cached = self::cache_get( $key ); if ( false !== $cached ) { return $cached; } $result = self::request( 'GET', '/links', array( 'query' => array( 'page' => $page, 'per_page' => $per_page ) ) ); if ( ! is_wp_error( $result ) ) { self::cache_set( $key, $result ); } return $result; } /** * slug => link data, cached. Lets the shortcode resolve a slug to its short URL + availability * without a request per shortcode. Fetches up to `$max` links (a few pages) — plenty for a * typical site; larger accounts should reference links that appear on the first pages. * * @param int $max Hard cap on links pulled into the map. * @return array|WP_Error slug => array, or WP_Error on the first failing page. */ public static function link_map( $max = 1000 ) { $cached = self::cache_get( 'linkmap' ); if ( false !== $cached ) { return $cached; } $map = array(); $page = 1; $per_page = 100; do { $batch = self::request( 'GET', '/links', array( 'query' => array( 'page' => $page, 'per_page' => $per_page ) ) ); if ( is_wp_error( $batch ) ) { return $batch; } $links = isset( $batch['links'] ) && is_array( $batch['links'] ) ? $batch['links'] : array(); foreach ( $links as $link ) { if ( empty( $link['slug'] ) ) { continue; } // Same slug can legally exist on different domains — key duplicates by slug+id so // the Links list shows every row (the shortcode resolves the first match). $key = isset( $map[ $link['slug'] ] ) ? $link['slug'] . '~' . $link['id'] : $link['slug']; $map[ $key ] = $link; } $total = isset( $batch['total'] ) ? (int) $batch['total'] : count( $map ); $fetched = $page * $per_page; ++$page; } while ( $fetched < $total && count( $map ) < $max ); self::cache_set( 'linkmap', $map ); return $map; } /** * GET /domains — verified branded short-link hosts, for the create form's domain picker. Cached. * * @return array|WP_Error */ public static function domains() { $cached = self::cache_get( 'domains' ); if ( false !== $cached ) { return $cached; } $result = self::request( 'GET', '/domains' ); if ( ! is_wp_error( $result ) ) { self::cache_set( 'domains', $result ); } return $result; } /** * GET /stats/summary — for the dashboard widget. Cached. * * @return array|WP_Error */ public static function summary() { $cached = self::cache_get( 'summary' ); if ( false !== $cached ) { return $cached; } $result = self::request( 'GET', '/stats/summary', array( 'query' => array( 'days' => 30 ) ) ); if ( ! is_wp_error( $result ) ) { self::cache_set( 'summary', $result ); } return $result; } /** * POST /links — create a link on the connected account. Busts the cache on success. * * @param array $data destination_url (required) + optional slug, is_affiliate, source_url. * @return array|WP_Error The created link, or WP_Error. */ public static function create_link( $data ) { $result = self::request( 'POST', '/links', array( 'body' => $data ) ); if ( ! is_wp_error( $result ) ) { self::flush_cache(); } return $result; } /** * PATCH /links/{id} — update fields on an existing link. Busts the cache on success. * * @param string $id Link id. * @param array $data Any of destination_url, is_affiliate, is_active, source_url. * @return array|WP_Error */ public static function update_link( $id, $data ) { $result = self::request( 'PATCH', '/links/' . rawurlencode( $id ), array( 'body' => $data ) ); if ( ! is_wp_error( $result ) ) { self::flush_cache(); } return $result; } /** * POST /links/{id}/clicks — report the absolute click total for a WordPress-hosted link (the * panel doesn't serve its redirect, so the plugin owns the count). Idempotent. * * @param string $id Remote link id. * @param int $count Current total clicks. * @return array|WP_Error */ public static function report_clicks( $id, $count, $daily = null ) { $body = array( 'clicks' => (int) $count ); if ( is_array( $daily ) && ! empty( $daily ) ) { $body['daily'] = $daily; // "YYYY-MM-DD" => count, absolute + idempotent like the total } return self::request( 'POST', '/links/' . rawurlencode( $id ) . '/clicks', array( 'body' => $body ) ); } /** * DELETE /links/{id}. Busts the cache on success. Returns true or WP_Error. * * @param string $id Link id. * @return true|WP_Error */ public static function delete_link( $id ) { $result = self::request( 'DELETE', '/links/' . rawurlencode( $id ) ); if ( is_wp_error( $result ) ) { return $result; } self::flush_cache(); return true; } /** * GET /stats/clicks — daily click counts (zero-filled) for the Stats chart. Cached. * * @param int $days Window length. * @return array|WP_Error */ public static function clicks_trend( $days = 30 ) { $days = max( 1, min( 365, (int) $days ) ); $key = 'clicks_' . $days; $cached = self::cache_get( $key ); if ( false !== $cached ) { return $cached; } $result = self::request( 'GET', '/stats/clicks', array( 'query' => array( 'days' => $days ) ) ); if ( ! is_wp_error( $result ) ) { self::cache_set( $key, $result ); } return $result; } /** * Remember the last connection check so admin screens can show status without a live call. * * @param array|WP_Error $me Result of self::me(). */ public static function remember_connection( $me ) { if ( is_wp_error( $me ) ) { delete_option( 'affkeeper_account' ); return; } update_option( 'affkeeper_account', array( 'username' => isset( $me['username'] ) ? sanitize_text_field( $me['username'] ) : '', 'plan' => isset( $me['plan'] ) ? sanitize_text_field( (string) $me['plan'] ) : '', 'monitoring' => ! empty( $me['monitoring'] ), 'checked' => time(), ), false ); } // --- transient cache helpers --- private static function cache_get( $key ) { return get_transient( self::CACHE_PREFIX . $key ); } private static function cache_set( $key, $value ) { set_transient( self::CACHE_PREFIX . $key, $value, self::CACHE_TTL ); } /** * Drop every cached response (called on reconnect / deactivate). */ public static function flush_cache() { global $wpdb; // Transients are stored as options; delete ours and their timeout twins. $like = $wpdb->esc_like( '_transient_' . self::CACHE_PREFIX ) . '%'; $to = $wpdb->esc_like( '_transient_timeout_' . self::CACHE_PREFIX ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- bulk transient cleanup, no core API for prefix delete. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $like, $to ) ); } }