remote_with_retry('https://api.allaccessible.org/validate', array( 'method' => 'POST', 'headers' => array('Content-Type' => 'application/json'), 'body' => json_encode(array( 'accountID' => $account_id, 'is_shopify' => null, 'browser' => isset($_SERVER['HTTP_USER_AGENT']) ? 'WordPress' : 'Unknown', 'device' => 'Server', 'pageUrl' => get_bloginfo('url'), 'auditUrl' => get_bloginfo('url'), )), 'timeout' => 15, )); if (is_wp_error($response)) { set_transient(self::ERROR_SENTINEL_TRANSIENT, 1, self::ERROR_SENTINEL_TTL); $memo = $response; return $response; } $body = wp_remote_retrieve_body($response); $data = json_decode($body); if ($data) { $subdomain_id = 0; if (isset($data->subID) && is_numeric($data->subID)) { $subdomain_id = (int) $data->subID; } elseif (isset($data->subdomainID) && is_numeric($data->subdomainID)) { $subdomain_id = (int) $data->subdomainID; } if ($subdomain_id > 0) { update_option('aacb_siteID', $subdomain_id); update_option('aacb_siteID_version', AACB_VERSION); } set_transient('aacb_site_options_cache', $data, self::CACHE_DURATION); delete_transient(self::ERROR_SENTINEL_TRANSIENT); $memo = $data; return $data; } set_transient(self::ERROR_SENTINEL_TRANSIENT, 1, self::ERROR_SENTINEL_TTL); $memo = new WP_Error('invalid_response', __('Invalid API response', 'allaccessible')); return $memo; } /** * Write top-level site options from PHP (merged server-side, shallow). * * The customizer and language card post to this same app route from the * browser; this wrapper exists for code paths with no browser, such as the * statement page's publish/unpublish hook. The route checks that accountID * owns the subdomain and busts the edge license cache, so the widget sees * the new value on its next load. * * Note: get_site_options() memoizes per request; a read later in the same * request still returns the pre-save payload. Callers reload instead. * * @param array $options top-level keys only, e.g. array('statementUrl' => 'https://…') * @return true|WP_Error */ public function save_site_options(array $options) { $account_id = (string) get_option('aacb_accountID'); $sub = (int) get_option('aacb_siteID'); if ($account_id === '' || $sub <= 0) { return new WP_Error('aacb_not_connected', __('Connect this site to AllAccessible first.', 'allaccessible')); } unset($options['accountID'], $options['subdomainID'], $options['subID'], $options['siteID']); $body = array_merge($options, array('accountID' => $account_id)); $response = wp_remote_post(self::APP_BASE_URL . '/api/save-site-options/' . $sub, array( 'timeout' => 10, 'headers' => array('Content-Type' => 'application/json'), 'body' => wp_json_encode($body), )); if (is_wp_error($response)) { return $response; } $code = (int) wp_remote_retrieve_response_code($response); if ($code !== 200) { /* translators: %d: HTTP status code */ return new WP_Error('aacb_save_failed', sprintf(__('AllAccessible did not accept the change (HTTP %d).', 'allaccessible'), $code)); } delete_transient('aacb_site_options_cache'); return true; } /** * The accessibility statement kept in the AllAccessible dashboard, as HTML, * or '' when none. Same unsigned edge call the on-page menu makes * (POST /statement with accountID + host); cached 10 minutes. * * @param bool $force_refresh bypass the transient * @return string */ public function get_statement_html($force_refresh = false) { $account_id = (string) get_option('aacb_accountID'); if ($account_id === '') { return ''; } $key = 'aacb_statement_html'; if (!$force_refresh) { $cached = get_transient($key); if (is_string($cached)) { return $cached; } } $response = wp_remote_post(self::API_BASE_URL . '/statement', array( 'timeout' => 8, 'headers' => array('Content-Type' => 'application/json'), 'body' => wp_json_encode(array( 'accountID' => $account_id, 'pageUrl' => (string) wp_parse_url(home_url('/'), PHP_URL_HOST), )), )); $html = ''; if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) === 200) { $body = (string) wp_remote_retrieve_body($response); $first = substr(ltrim($body), 0, 1); // The endpoint returns raw HTML; JSON means an error envelope (or the dev stub's default). $html = ($first === '{' || $first === '[') ? '' : $body; } set_transient($key, $html, 10 * MINUTE_IN_SECONDS); return $html; } /** * Get current subscription tier from API * * @return string Subscription tier: 'free', 'trial', 'starter', 'legacy', 'enterprise' */ public function get_subscription_tier() { $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { return 'free'; } if (isset($site_options->_meta->pricingTier)) { return $site_options->_meta->pricingTier; } if (isset($site_options->tier)) { return $site_options->tier; } // Free accounts legitimately come back with no pricingTier/tier field, // so a missing tier is the EXPECTED free-tier path, not an anomaly. // Log locally for debugging but do NOT report — warn() would send this // to error reporting and flood it with normal free-tier traffic. AllAccessible_Debug::info('ApiClient::get_subscription_tier', 'No tier in response — defaulting to free'); return 'free'; } /** * Check if account is paid * * @return bool True if paid subscription (starter, enterprise, legacy, or trial) */ public function is_paid_account() { $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { return false; } if (isset($site_options->paid)) { return (bool) $site_options->paid; } $tier = $this->get_subscription_tier(); return in_array($tier, array('starter', 'enterprise', 'legacy', 'trial')); } /** * Check if account has exceeded limits * * @return array Array of exceeded limit names, or empty array */ public function get_exceeded_limits() { $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { return array(); } if (isset($site_options->exceededLimits) && is_array($site_options->exceededLimits)) { return $site_options->exceededLimits; } return array(); } /** * Get usage summary for display * * @return array|null Usage summary or null if not available */ public function get_usage_summary() { $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { return null; } if (isset($site_options->usageSummary)) { return $site_options->usageSummary; } return null; } /** * Get site ID from API * * @return string|null Site ID or null if not available */ public function get_site_id() { $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { // Fallback to WordPress stored siteID return get_option('aacb_siteID'); } if (isset($site_options->siteID)) { return $site_options->siteID; } return get_option('aacb_siteID'); } /** * Get subscription ID from API * * @return int|null Subscription ID or null if not available */ public function get_subscription_id() { $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { return null; } if (isset($site_options->subID)) { return $site_options->subID; } return null; } /** * Get billing URL * * @return string Billing portal URL */ public function get_billing_url() { return 'https://app.allaccessible.org/billing'; } /** * Get addon URL for this site * * @return string Addon management URL */ public function get_addon_url() { $site_id = $this->get_site_id(); if ($site_id) { return 'https://app.allaccessible.org/site/' . $site_id . '/addons'; } // Fallback to billing return $this->get_billing_url(); } /** * Legacy V1 → current-tier migration landing page. */ public function get_migration_url() { $site_id = $this->get_site_id(); if ($site_id) { return 'https://app.allaccessible.org/site/' . rawurlencode((string) $site_id) . '/migration/offer'; } return $this->get_billing_url(); } /** * Get accessibility audits URL * * @return string Audits URL */ public function get_audits_url() { $site_id = $this->get_site_id(); $sub_id = $this->get_subscription_id(); if ($site_id && $sub_id) { return 'https://app.allaccessible.org/site/' . $site_id . '/' . $sub_id . '/accessibility-audits'; } // Fallback to site overview if ($site_id) { return 'https://app.allaccessible.org/site/' . $site_id; } return 'https://app.allaccessible.org'; } /** * Get advanced widget settings URL * * @return string Widget settings URL */ public function get_widget_settings_url() { $site_id = $this->get_site_id(); $sub_id = $this->get_subscription_id(); if ($site_id && $sub_id) { return 'https://app.allaccessible.org/site/' . $site_id . '/' . $sub_id . '/widget-settings'; } // Fallback to site overview if ($site_id) { return 'https://app.allaccessible.org/site/' . $site_id; } return 'https://app.allaccessible.org'; } /* ================================================================== * Agentic Fixes API * ================================================================== */ const APP_BASE_URL = 'https://app.allaccessible.org'; const PLATFORM_HEADER = 'wordpress'; const CACHE_MANIFEST_SUMMARY = 'aacb_cache_manifest_summary_v2'; const PLUGIN_SECRET_OPTION = 'aacb_plugin_secret'; const PLUGIN_SECRET_CANON_OPT = 'aacb_plugin_secret_canon'; const INSTALL_TOKEN_OPTION = 'aacb_install_token'; /** * Latest aggregated audit for this site. * * @param bool $force_refresh Skip transient cache when true. * @return array|WP_Error */ public function get_audit_aggregation($force_refresh = false) { $cache_key = 'aacb_cache_audit_aggregation_v1'; if (!$force_refresh) { $cached = get_transient($cache_key); if ($cached !== false) { return $cached; } } $result = $this->signed_app_request('GET', self::PLUGIN_API_PREFIX . '/scans/audit/aggregation', null); if (!is_wp_error($result)) { set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS); } return $result; } /** * Trigger a single-page audit scan immediately. * * @param string $page_url Absolute URL to scan. Must live on this site. * @param string $source One of: wp_admin_bar (default), wp_metabox, * app_manual, api_external. Backend will reject * unknown values silently → wp_admin_bar. * @return array|WP_Error */ public function trigger_page_scan($page_url, $source = 'wp_admin_bar') { if (empty($page_url) || !is_string($page_url)) { return new WP_Error( 'invalid_page_url', __('A page URL is required to trigger a scan.', 'allaccessible') ); } $body = array( 'pageUrl' => $page_url, 'source' => $source, ); $result = $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/scans/start', $body ); if (!is_wp_error($result)) { $this->bust_page_caches($page_url); } return $result; } /** * Invalidate every plugin transient that could surface a stale score * for the given URL. */ public function bust_page_caches($page_url) { // Per-URL transients (warmed by get_page_audit). $url_hash = md5((string) $page_url); delete_transient('aacb_page_score_' . $url_hash); delete_transient('aacb_page_audit_meta_' . $url_hash); $reverse_id = url_to_postid((string) $page_url); $key_parts_zero = array(0, $url_hash); delete_transient('aacb_cache_page_audit_v1_' . md5(implode('|', $key_parts_zero))); if ($reverse_id > 0) { $key_parts_post = array((int) $reverse_id, $url_hash); delete_transient('aacb_cache_page_audit_v1_' . md5(implode('|', $key_parts_post))); } // Site-level surfaces that include this page in summaries. delete_transient('aacb_cache_audit_aggregation_v1'); global $wpdb; if (isset($wpdb)) { $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_aacb_cache_pages_audit_bulk_v2_%' OR option_name LIKE '_transient_timeout_aacb_cache_pages_audit_bulk_v2_%'" ); } } /** * Task stats counts * * @param bool $force_refresh * @return array|WP_Error ['ai_resolved_count', 'manual_required_count', * 'completed_count', 'ignored_count', * 'total_actionable', 'has_audit_data', 'tier', ...] */ public function get_task_stats($force_refresh = false) { $cache_key = 'aacb_cache_task_stats_v2'; if (!$force_refresh) { $cached = get_transient($cache_key); if ($cached !== false) { return $cached; } } $result = $this->signed_app_request('GET', self::PLUGIN_API_PREFIX . '/tasks/stats', null); if (!is_wp_error($result)) { set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS); } return $result; } /** * Full rule-by-rule list * * @param bool $force_refresh * @return array|WP_Error ['rules' => [...], 'has_audit_data' => bool, 'tier'] */ /** * Per-page audit lookup. * * @param int|null $post_id WP post ID (preferred — indexed lookup) * @param string|null $page_url Permalink fallback (used when post_id * hasn't been linked server-side yet). * @return array|WP_Error ['overall_score', 'issues', 'last_scan', * 'audit_status', 'data_source', ...] */ public function get_page_audit($post_id = null, $page_url = null) { $key_parts = array( $post_id ? (int) $post_id : 0, $page_url ? md5((string) $page_url) : '', ); $cache_key = 'aacb_cache_page_audit_v1_' . md5(implode('|', $key_parts)); $cached = get_transient($cache_key); if ($cached !== false) return $cached; $args = array(); if ($post_id) $args['post_id'] = (int) $post_id; if ($page_url) $args['page_url'] = $page_url; if (empty($args)) { return new WP_Error('missing_arg', __('post_id or page_url required', 'allaccessible')); } $path = self::PLUGIN_API_PREFIX . '/audits/by-page?' . http_build_query($args); $result = $this->signed_app_request('GET', $path, null); if (!is_wp_error($result)) { set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS); if ($page_url && is_array($result)) { $score = null; if (isset($result['overall_score']) && is_numeric($result['overall_score'])) { $score = (int) $result['overall_score']; } elseif (isset($result['score_breakdown']['current']) && is_numeric($result['score_breakdown']['current'])) { $score = (int) $result['score_breakdown']['current']; } if ($score !== null && class_exists('AllAccessible_AdminBar')) { set_transient( AllAccessible_AdminBar::PAGE_SCORE_TRANSIENT_PREFIX . md5($page_url), $score, 5 * MINUTE_IN_SECONDS ); if (!empty($result['audit_id']) && !empty($result['subdomain_id'])) { set_transient( AllAccessible_AdminBar::PAGE_AUDIT_META_TRANSIENT_PREFIX . md5($page_url), array( 'audit_id' => (int) $result['audit_id'], 'subdomain_id' => (int) $result['subdomain_id'], ), 5 * MINUTE_IN_SECONDS ); } } } } return $result; } /** * Bulk per-page lookup for the WP All Posts / All Pages column. * * @param bool $force_refresh * @return array|WP_Error ['pages' => [{post_id, page_url, audit_status, ...}, ...]] */ public function get_pages_audit_bulk($force_refresh = false, array $post_ids = array(), array $include = array(), array $page_urls = array()) { $key_suffix = empty($post_ids) ? 'all' : md5(implode(',', array_map('intval', $post_ids))); $inc_suffix = empty($include) ? '' : '_inc_' . md5(implode(',', $include)); $url_suffix = empty($page_urls) ? '' : '_url_' . md5(implode('|', $page_urls)); $cache_key = 'aacb_cache_pages_audit_bulk_v2_' . $key_suffix . $inc_suffix . $url_suffix; if (!$force_refresh) { $cached = get_transient($cache_key); if ($cached !== false) return $cached; } // post_ids + page_urls — backend matches by either post id or // canonical URL, covering rows not yet linked to a WP post id. // Without either, falls back to the "all pages up to limit" path. $args = array(); if (!empty($post_ids)) { $args['post_ids'] = implode(',', array_map('intval', $post_ids)); } else { $args['platform'] = ''; } if (!empty($page_urls)) { // Pipe delimiter — commas appear in some URLs. Backend explode('|'). $args['page_urls'] = implode('|', $page_urls); } // include= opt-in for Dashboard consolidation. Backend returns // additional data blocks alongside the standard pages array — // collapses several round trips into one. if (!empty($include)) { $args['include'] = implode(',', $include); } $path = self::PLUGIN_API_PREFIX . '/audits/by-subdomain?' . http_build_query($args); $result = $this->signed_app_request('GET', $path, null); if (!is_wp_error($result)) { // Shorter TTL when scoped to specific IDs — admin is // actively viewing those pages, fresh scores matter more. $ttl = empty($post_ids) ? 10 * MINUTE_IN_SECONDS : 2 * MINUTE_IN_SECONDS; set_transient($cache_key, $result, $ttl); } return $result; } /** * Background-link a WP post ID to its scanned page row. * * Fired fire-and-forget from EditorMetaBox on load. Backend links the post * id to the matching scanned page by canonical URL when not already linked. * Idempotent. Failure is silent — the URL fallback path keeps working. * * @param int $post_id * @param string $page_url Permalink of the post * @return array|WP_Error */ public function link_post_to_page($post_id, $page_url) { if (!$post_id || !$page_url) { return new WP_Error('missing_arg', __('post_id and page_url required', 'allaccessible')); } return $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/audits/link-post', array('post_id' => (int) $post_id, 'page_url' => (string) $page_url), false, array('blocking' => false) ); } /** * Bulk variant of link_post_to_page — used by the activation / * daily-cron backfill that walks all published WP posts and pushes * their post_id ↔ permalink pairs to the backend in one shot per * batch. Server cap is 100 pairs per call; caller is expected to * chunk larger sets. * * @param array $pairs [{post_id: int, page_url: string}, ...] * @return array|WP_Error ['linked', 'skipped', 'missing'] */ public function link_posts_batch(array $pairs) { if (empty($pairs)) { return new WP_Error('missing_arg', __('pairs required', 'allaccessible')); } if (count($pairs) > 100) { return new WP_Error('batch_too_large', __('batch limited to 100 pairs', 'allaccessible')); } return $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/audits/link-post-batch', array('posts' => array_values($pairs)) ); } public function get_task_categorization($force_refresh = false) { // v2 cache key — see get_task_stats note. Field names are normalized // server-side now, so any pre-deploy transient with the old field // keys is stale. $cache_key = 'aacb_cache_task_categorization_v2'; if (!$force_refresh) { $cached = get_transient($cache_key); if ($cached !== false) { return $cached; } } $result = $this->signed_app_request('GET', self::PLUGIN_API_PREFIX . '/tasks/categorization', null); if (!is_wp_error($result)) { set_transient($cache_key, $result, 5 * MINUTE_IN_SECONDS); } return $result; } /** * Drop every cached manifest summary (all status tabs, fresh + last-good + * failure sentinel). Called after approve/revert so counts refresh. */ private function forget_manifest_summary_cache() { foreach (array('', '_draft', '_approved', '_reverted') as $suffix) { AllAccessible_SummaryCache::forget(self::CACHE_MANIFEST_SUMMARY . $suffix); } } /** * Site-wide manifest aggregate. Hits the AllAccessible API. Cached as * a transient for 30 min so the dashboard widget can render without * hammering the API. * * @param bool $force_refresh Skip transient cache when true. * @param int|null $short_ttl When set, a successful fetch is cached for this many * seconds only and the last-good copy is not updated. * @return array|WP_Error */ public function get_manifest_summary($force_refresh = false, $status = null, $short_ttl = null) { $valid_status = in_array($status, array('draft', 'approved', 'reverted'), true) ? $status : null; $this->_summary_status_filter = $valid_status; // Cache key includes status so each tab keeps a separate transient. $cache_key = self::CACHE_MANIFEST_SUMMARY . ($valid_status ? ('_' . $valid_status) : ''); if (!$force_refresh) { $cached = AllAccessible_SummaryCache::fresh($cache_key); if ($cached !== false) { return $cached; } // A recent fetch failed (the summary service can take 10s+ on a cold // start for large sites). Do not block this admin render on another // attempt: serve the last good copy if we have one, else fail fast. // WORDPRESS-PLUGIN-N: 2,006 timeouts across 15 sites before 2.1.7. if (AllAccessible_SummaryCache::should_skip_network($cache_key)) { $stale = AllAccessible_SummaryCache::last_good($cache_key); return $stale !== false ? $stale : new WP_Error('api_request_failed', __('AllAccessible API temporarily unreachable', 'allaccessible')); } } $account_id = get_option('aacb_accountID'); if (!$account_id) { return new WP_Error('no_account_id', __('Account not configured', 'allaccessible')); } // Server requires siteId — without it, an account that owns multiple // WP installs would see aggregate data across all of them. Always // resolve to the site that matches THIS WordPress install before // scoping the request. $site_id = $this->resolve_site_id(); if (is_wp_error($site_id)) return $site_id; $args = array( 'accountID' => $account_id, 'siteId' => $site_id, 'locale' => $this->wp_locale(), ); // Optional status filter — UI passes draft|approved|reverted to drive // status tabs. Server defaults to 'draft' if unset. $status = isset($this->_summary_status_filter) ? $this->_summary_status_filter : null; if ($status) { $args['status'] = $status; } // A forced refresh follows a write (approve/revert redirect). Ask the // server for a read-your-writes answer so a lagging replica cannot // hand back the just-approved fix as still pending. if ($force_refresh) { $args['fresh'] = '1'; } $url = self::API_BASE_URL . self::PLUGIN_API_PREFIX . '/manifest/summary?' . http_build_query($args); $response = $this->remote_with_retry($url, array( 'timeout' => 10, 'headers' => array( 'Accept' => 'application/json', 'User-Agent' => 'AllAccessible-WordPress/' . AACB_VERSION, ), )); $decoded = $this->decode_json_response($response, '/manifest/summary'); if (is_wp_error($decoded)) { AllAccessible_SummaryCache::remember_failure($cache_key); $stale = AllAccessible_SummaryCache::last_good($cache_key); return $stale !== false ? $stale : $decoded; } if ($short_ttl) { AllAccessible_SummaryCache::remember_short($cache_key, $decoded, $short_ttl); } else { AllAccessible_SummaryCache::remember_success($cache_key, $decoded); } return $decoded; } /** Shorten the cached fresh summary for a status tab so the next visit refetches soon. */ public function shorten_manifest_summary_cache($status, $ttl) { $valid_status = in_array($status, array('draft', 'approved', 'reverted'), true) ? $status : null; AllAccessible_SummaryCache::shorten(self::CACHE_MANIFEST_SUMMARY . ($valid_status ? ('_' . $valid_status) : ''), $ttl); } /** Per-call status filter — set by get_manifest_summary, read by URL builder. */ private $_summary_status_filter = null; /** * Paginated alt-text grid for the Image Description Manager (KAN-19). * Read endpoint — simple query-string auth. Server validates account/site * ownership before returning rows. * See docs/plugin-architecture-internals.md (internal) for the response shape. * * @param array $args { * @type int $page 1-based page number (default 1) * @type int $perPage Items per page, max 100 (default 24) * @type string $filter 'all' | 'missing' | 'ai' | 'manual' * } * @return array|WP_Error */ public function get_plugin_images($args = array()) { $account_id = get_option('aacb_accountID'); if (!$account_id) { return new WP_Error('no_account_id', __('Account not configured', 'allaccessible')); } $site_id = $this->resolve_site_id(); if (is_wp_error($site_id)) return $site_id; $query = array( 'accountID' => $account_id, 'siteId' => $site_id, 'page' => isset($args['page']) ? max(1, (int) $args['page']) : 1, 'perPage' => isset($args['perPage']) ? min(100, max(1, (int) $args['perPage'])) : 24, 'filter' => isset($args['filter']) && in_array($args['filter'], array('all','missing','ai','manual'), true) ? $args['filter'] : 'all', ); // Short transient (60s) — Image Manager page re-renders on every // pagination/filter click. Without this, every render hit Api. // Keyed on the query shape so different filters/pages don't share. // // Version-stamp pattern: the cache key embeds a stamp from the // `aacb_image_grid_cache_stamp` option. flush_image_grid_cache() // bumps the stamp, which changes EVERY future cache key — so the // next read misses regardless of whether the underlying object // cache was actually purged. A wpdb DELETE alone wasn't enough — it // only kills DB-backed transients, while a persistent object cache // layer kept serving stale rows. $stamp = (int) get_option('aacb_image_grid_cache_stamp', 1); $cache_key = 'aacb_cache_plugin_images_v1_' . $stamp . '_' . md5(serialize($query)); $cached = get_transient($cache_key); if ($cached !== false) return $cached; $url = self::API_BASE_URL . self::PLUGIN_API_PREFIX . '/images?' . http_build_query($query); $response = $this->remote_with_retry($url, array( 'timeout' => 10, 'headers' => array( 'Accept' => 'application/json', 'User-Agent' => 'AllAccessible-WordPress/' . AACB_VERSION, ), )); $decoded = $this->decode_json_response($response, '/images'); if (!is_wp_error($decoded)) { set_transient($cache_key, $decoded, MINUTE_IN_SECONDS); } return $decoded; } /** * Full detail for a single manifest. Not cached — always fresh. */ public function get_manifest_detail($manifest_id) { $manifest_id = (int) $manifest_id; if ($manifest_id <= 0) { return new WP_Error('bad_manifest_id', __('Invalid manifest ID', 'allaccessible')); } return $this->signed_app_request( 'GET', self::PLUGIN_API_PREFIX . '/manifest/' . $manifest_id, null ); } /** * Approve + activate a manifest. Free tier returns 403 from server. */ public function approve_manifest($manifest_id) { $manifest_id = (int) $manifest_id; if ($manifest_id <= 0) { return new WP_Error('bad_manifest_id', __('Invalid manifest ID', 'allaccessible')); } $result = $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/manifest/' . $manifest_id . '/approve', new stdClass() // empty JSON object; server accepts no body fields here ); if (!is_wp_error($result)) { $this->clear_cache(); $this->forget_manifest_summary_cache(); } return $result; } /** * Revert a manifest with a customer-supplied reason. */ public function revert_manifest($manifest_id, $reason) { $manifest_id = (int) $manifest_id; if ($manifest_id <= 0) { return new WP_Error('bad_manifest_id', __('Invalid manifest ID', 'allaccessible')); } $reason = is_string($reason) ? sanitize_text_field($reason) : ''; if (strlen($reason) > 500) { $reason = substr($reason, 0, 500); } $result = $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/manifest/' . $manifest_id . '/revert', array('reason' => $reason) ); if (!is_wp_error($result)) { $this->clear_cache(); $this->forget_manifest_summary_cache(); } return $result; } /** * Edit a single fix payload (alt text wording, button label, etc.). * Only draft manifests are editable per server rules. */ public function edit_fix($manifest_id, $fix_index, $value) { $manifest_id = (int) $manifest_id; $fix_index = (int) $fix_index; if ($manifest_id <= 0 || $fix_index < 0) { return new WP_Error('bad_params', __('Invalid manifest or fix index', 'allaccessible')); } $value = is_string($value) ? wp_kses_post($value) : ''; if ($value === '') { return new WP_Error('empty_value', __('Value is required', 'allaccessible')); } if (mb_strlen($value) > 500) { return new WP_Error('value_too_long', __('Value too long (max 500 characters)', 'allaccessible')); } $result = $this->signed_app_request( 'PATCH', self::PLUGIN_API_PREFIX . '/manifest/' . $manifest_id . '/fix/' . $fix_index, array('value' => $value) ); if (!is_wp_error($result)) { $this->clear_cache(); $this->forget_manifest_summary_cache(); } return $result; } /** * Bulk approve up to 100 manifests in one call. */ public function bulk_approve_manifests($site_id, array $manifest_ids) { $site_id = (int) $site_id; if ($site_id <= 0) { return new WP_Error('bad_site_id', __('Invalid site ID', 'allaccessible')); } $ids = array_values(array_filter(array_map('intval', $manifest_ids), function($v) { return $v > 0; })); if (empty($ids)) { return new WP_Error('no_ids', __('No manifest IDs supplied', 'allaccessible')); } $ids = array_slice($ids, 0, 100); $result = $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/site/' . $site_id . '/bulk-approve', array('manifestIds' => $ids) ); if (!is_wp_error($result)) { $this->clear_cache(); // CACHE_MANIFEST_SUMMARY base key is suffixed by status filter // (`_draft`, `_approved`, `_reverted`) when actually stored — // delete_transient on the base prefix leaves the per-status // transients intact. Wipe the prefix via wpdb so the next // render of any tab sees fresh data. Also nuke aggregation + // page-bulk caches because approved fixes change live scores. $this->bust_manifest_caches(); } return $result; } /** * Invalidate every transient that surfaces manifest summaries, audit * aggregations, or per-page scores. Called after bulk approve so the * Agentic Fixes tabs + Dashboard tiles + page-list column all show * the freshly-approved state on next render. Cheap — runs zero queries * when no matching rows exist. */ public function bust_manifest_caches() { global $wpdb; // Per-status manifest summary transients (draft/approved/reverted). $this->forget_manifest_summary_cache(); if (isset($wpdb)) { $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_aacb_cache_manifest_summary_v2_%' OR option_name LIKE '_transient_timeout_aacb_cache_manifest_summary_v2_%' OR option_name LIKE '_transient_aacb_cache_pages_audit_bulk_v2_%' OR option_name LIKE '_transient_timeout_aacb_cache_pages_audit_bulk_v2_%'" ); } delete_transient('aacb_cache_audit_aggregation_v1'); } /** * Override alt text on a single image row (KAN-19). Signed mutation sent * to the App service. Caller must validate input length (1-500 chars) * before this is called; server enforces the same bounds defensively. * * @param int $image_id Image row id. * @param string $text New alt text. Trimmed by server. * @return array|WP_Error Updated row projection on success. */ public function override_image_alt_text($image_id, $text) { $image_id = (int) $image_id; if ($image_id <= 0) { return new WP_Error('bad_image_id', __('Invalid image ID', 'allaccessible')); } $text = is_string($text) ? sanitize_text_field($text) : ''; if ($text === '' || mb_strlen($text) > 500) { return new WP_Error('bad_text', __('Alt text must be 1–500 characters', 'allaccessible')); } $result = $this->signed_app_request( 'PATCH', self::PLUGIN_API_PREFIX . '/images/' . $image_id, array('text' => $text) ); // Invalidate the images grid cache so the next page render reflects // this edit immediately (no 60s stale window). if (!is_wp_error($result)) { $this->flush_image_grid_cache(); } return $result; } /** * Wipe every plugin-side API transient — pages bulk, per-page audit, * images grid, task stats, manifest summary, site options, audit * aggregation, scores, etc. Used by the "Flush plugin caches" * button on Account → Advanced and by mutation paths that need to * invalidate aggressively. Single targeted DELETE; no transient_keys() * enumeration cost. */ public function flush_all_caches() { global $wpdb; $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_aacb_cache_%' OR option_name LIKE '_transient_timeout_aacb_cache_%' OR option_name LIKE '_transient_aacb_site_options_cache%' OR option_name LIKE '_transient_timeout_aacb_site_options_cache%'" ); } /** * Wipe every get_plugin_images() transient. */ private function flush_image_grid_cache() { $current = (int) get_option('aacb_image_grid_cache_stamp', 1); update_option('aacb_image_grid_cache_stamp', $current + 1, false); } /** * Return the stored signing secret. */ public function get_plugin_secret() { $secret = get_option(self::PLUGIN_SECRET_OPTION, ''); $stamp = (string) get_option('aacb_plugin_secret_version', ''); if (!empty($secret) && $stamp === AACB_VERSION) { return $secret; } $fetched = $this->fetch_plugin_secret(); return is_wp_error($fetched) ? '' : $fetched; } /** * Random token that identifies this install to AllAccessible. * * Generated once, stored with autoload off, kept across re-validation * (invalidate_site_resolution() leaves it alone) and removed only by * uninstall. The credential server asks this site to answer * HMAC(token, nonce) through the public challenge route before it issues * a signing secret, so only a caller that holds this token can obtain one. * * @param bool $create Generate the token if none exists yet. * @return string Empty string when none exists and $create is false. */ public function get_install_token($create = true) { $token = (string) get_option(self::INSTALL_TOKEN_OPTION, ''); if ($token !== '' || !$create) { return $token; } try { $token = bin2hex(random_bytes(32)); } catch (Exception $e) { $token = hash('sha256', wp_generate_password(64, true, true) . microtime(true)); } $this->save_option_no_autoload(self::INSTALL_TOKEN_OPTION, $token); return $token; } /** * One-shot fetch of the signing secret. * * 2.2.0: POST to the app with this install's token. The server confirms * the site answers the challenge route with the same token before it * issues a credential (see inc/api/CredentialChallenge.php). Earlier * versions did a plain GET that anyone with the public account ID could * repeat; that endpoint now answers 410. * * @return string|WP_Error */ public function fetch_plugin_secret() { $account_id = get_option('aacb_accountID'); if (!$account_id) { return new WP_Error('no_account_id', __('Account not configured', 'allaccessible')); } $site_url = get_site_url(); $url = self::APP_BASE_URL . self::PLUGIN_API_PREFIX . '/secret'; $response = $this->remote_with_retry($url, array( 'method' => 'POST', // The server calls back into this site while we wait, so leave // room for that round trip on slow hosts. 'timeout' => 20, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => 'AllAccessible-WordPress/' . AACB_VERSION, ), 'body' => wp_json_encode(array( 'accountID' => $account_id, 'siteUrl' => $site_url, 'installToken' => $this->get_install_token(true), 'pluginVersion' => AACB_VERSION, 'platform' => self::PLATFORM_HEADER, )), )); $decoded = $this->decode_json_response($response, '/secret'); if (is_wp_error($decoded)) { $data = $decoded->get_error_data(); $status = is_array($data) && isset($data['status']) ? (int) $data['status'] : 0; // 404 = site not registered yet, 502 = our site was not reachable // from AllAccessible right now. Both clear up on their own; try // again shortly in the background instead of waiting for the next // admin click. if (in_array($status, array(404, 502, 429), true) && !wp_next_scheduled('aacb_fetch_plugin_secret_event')) { wp_schedule_single_event(time() + 120, 'aacb_fetch_plugin_secret_event'); } return $decoded; } if (is_wp_error($decoded)) { return $decoded; } if (empty($decoded['pluginSecret']) || empty($decoded['canonicalSiteUrl'])) { return new WP_Error('bad_secret_response', __('Server returned invalid plugin secret payload', 'allaccessible')); } // Stored WITHOUT autoload — the signing secret must not ride // wp_load_alloptions() into every frontend request. delete+add // (rather than update_option's third arg) also flips autoload // on rows that existing installs already created as 'yes'. $this->save_option_no_autoload(self::PLUGIN_SECRET_OPTION, $decoded['pluginSecret']); $this->save_option_no_autoload(self::PLUGIN_SECRET_CANON_OPT, $decoded['canonicalSiteUrl']); $this->save_option_no_autoload('aacb_plugin_secret_version', AACB_VERSION); // A retry queued by an earlier failure would now repeat the challenge // round trip for nothing. wp_clear_scheduled_hook('aacb_fetch_plugin_secret_event'); return $decoded['pluginSecret']; } /** * Persist an option with autoload=no on every WP version we support. */ private function save_option_no_autoload($name, $value) { delete_option($name); add_option($name, $value, '', 'no'); } /* ─── Scan trigger ───────────────────────── */ /** * Resolve the per-site id (stored as aacb_siteID). * * @return int|WP_Error Positive int site id, or WP_Error. */ private function resolve_site_id() { $site_id = (int) get_option('aacb_siteID'); $stamp = (string) get_option('aacb_siteID_version', ''); if ($site_id > 0 && $stamp === AACB_VERSION) { return $site_id; } $site_options = $this->get_site_options(); if (is_wp_error($site_options)) { return $site_options; } $site_id = (int) get_option('aacb_siteID'); if ($site_id <= 0) { return new WP_Error('no_site_id', __('No site associated with this account yet. Visit your WordPress homepage once to register this site, then return here.', 'allaccessible')); } return $site_id; } /** * Bust the site-options transient + persistent siteID option. */ public function invalidate_site_resolution() { delete_transient('aacb_site_options_cache'); delete_option('aacb_siteID'); delete_option('aacb_siteID_version'); delete_option(self::PLUGIN_SECRET_OPTION); delete_option(self::PLUGIN_SECRET_CANON_OPT); delete_option('aacb_plugin_secret_version'); } /** * Start a full-site scan and report what the server decided. * * 2.2: this used to be fire-and-forget, so a scan the server refused * (one already in flight, cooldown, plan without full-site scans) still * stamped "Last scan triggered" and told the admin results were coming. * Now it waits for the verdict and records it for the dashboard. * * @param string $sitemap_url Absolute sitemap URL (may be empty to skip ingest) * @param string $viewport 'desktop' | 'mobile' | 'both' * @return array|false false = plugin secret not ready yet (caller retries); * else { state: queued|unconfirmed|blocked, reason, message, jobId } */ public function start_scan_workflow($sitemap_url = '', $viewport = 'both') { $sitemap_url = is_string($sitemap_url) ? esc_url_raw($sitemap_url) : ''; if (!in_array($viewport, array('desktop', 'mobile', 'both'), true)) { $viewport = 'both'; } if (empty(get_option(self::PLUGIN_SECRET_OPTION, ''))) { wp_schedule_single_event(time() + 1, 'aacb_fetch_plugin_secret_event'); return false; } $result = $this->signed_app_request( 'POST', self::PLUGIN_API_PREFIX . '/scans/start', array('sitemapUrl' => $sitemap_url, 'viewport' => $viewport), false, array('timeout' => 25) // sitemap ingest runs before the crawl starts ); if (is_wp_error($result) && $result->get_error_code() === 'no_plugin_secret') { return false; } if (is_wp_error($result) && $result->get_error_code() === 'api_request_failed') { // Transport timeout: the request may still be running server-side. // Same outcome the old fire-and-forget path assumed. $outcome = array('state' => 'unconfirmed', 'reason' => '', 'message' => '', 'jobId' => null); } elseif (is_wp_error($result)) { $data = $result->get_error_data(); $status = is_array($data) && isset($data['status']) ? (int) $data['status'] : 0; $reason = self::classify_scan_rejection($status, $result->get_error_message()); $outcome = array('state' => 'blocked', 'reason' => $reason, 'message' => self::scan_rejection_message($reason), 'jobId' => null); } else { $job_id = isset($result['jobId']) ? $result['jobId'] : null; $outcome = array('state' => 'queued', 'reason' => '', 'message' => '', 'jobId' => $job_id); } if ($outcome['state'] !== 'blocked') { delete_transient(self::CACHE_MANIFEST_SUMMARY . '_draft'); delete_transient(self::CACHE_MANIFEST_SUMMARY . '_approved'); delete_transient('aacb_cache_audit_aggregation_v1'); delete_transient('aacb_cache_scan_schedule'); } update_option('aacb_last_scan_triggered_at', time()); update_option('aacb_last_scan_sitemap', $sitemap_url); update_option('aacb_last_scan_result', array('state' => $outcome['state'], 'reason' => $outcome['reason']), false); return $outcome; } /** * Map a refused /scans/start to a stable reason code. Uses the HTTP status * when the server passes it through, and the message text otherwise * (older servers wrapped every crawler refusal in a 502). */ public static function classify_scan_rejection($status, $message) { $m = strtolower((string) $message); if ($status === 409 || strpos($m, 'already in progress') !== false) return 'in_progress'; if ($status === 429 || strpos($m, 'window not yet elapsed') !== false || strpos($m, 'rate limit') !== false) return 'cooldown'; if ($status === 402 || strpos($m, 'addon') !== false) return 'not_included'; if (strpos($m, 'no urls available') !== false) return 'no_pages'; return 'error'; } /** Customer-facing sentence for a scan refusal reason. */ public static function scan_rejection_message($reason) { switch ($reason) { case 'in_progress': return __('A scan is already running for this site, so a new one was not started. If this keeps happening, contact support.', 'allaccessible'); case 'cooldown': return __('A full-site scan ran recently, and your plan allows one per scan period. You can still rescan a single page from the admin bar while viewing it.', 'allaccessible'); case 'not_included': return __('Full-site scans are not included in this site\'s plan.', 'allaccessible'); case 'no_pages': return __('No pages were found to scan. Add your sitemap URL and try again.', 'allaccessible'); default: return __('The scan could not be started. Try again in a few minutes.', 'allaccessible'); } } /** * Fetch tier-aware scan schedule from the App service. * * @return array|WP_Error { scanFrequency, scanFrequencyLabel, scanFrequencyDays, * planTier, lastScanAt, nextScanAt, sitemapUrl } */ public function get_scan_schedule($force_refresh = false) { $cache_key = 'aacb_cache_scan_schedule'; if (!$force_refresh) { $cached = get_transient($cache_key); if ($cached !== false) return $cached; } $result = $this->signed_app_request('GET', self::PLUGIN_API_PREFIX . '/scans/schedule', null); if (!is_wp_error($result)) { set_transient($cache_key, $result, HOUR_IN_SECONDS); } return $result; } /** * Read the last-triggered timestamp + sitemap URL for UI display. * * @return array { triggeredAt: int|null, sitemapUrl: string|null, state: string, reason: string } */ public function get_last_scan_meta() { $ts = (int) get_option('aacb_last_scan_triggered_at', 0); $res = get_option('aacb_last_scan_result', array()); $res = is_array($res) ? $res : array(); return array( 'triggeredAt' => $ts > 0 ? $ts : null, 'sitemapUrl' => (string) get_option('aacb_last_scan_sitemap', ''), // '' for requests made before 2.2 recorded the verdict. 'state' => isset($res['state']) ? (string) $res['state'] : '', 'reason' => isset($res['reason']) ? (string) $res['reason'] : '', ); } /** * Check whether this site can start a crawl right now. * * @return array|WP_Error */ public function check_scan_eligibility() { $site_id = $this->resolve_site_id(); if (is_wp_error($site_id)) return $site_id; $url = self::API_BASE_URL . '/api/crawler/eligibility?' . http_build_query(array( 'subdomainId' => $site_id, )); $response = $this->remote_with_retry($url, array( 'timeout' => 10, 'headers' => array( 'Accept' => 'application/json', 'User-Agent' => 'AllAccessible-WordPress/' . AACB_VERSION, ), )); return $this->decode_json_response($response, '/api/crawler/eligibility'); } /** * Poll scan progress. * * @param int $job_id Returned by start_scan() or trigger_page_scan(). * @return array|WP_Error ['job' => [...], 'pages' => [...], 'counts' => [...]] */ public function get_scan_status($job_id) { $job_id = (int) $job_id; if ($job_id <= 0) { return new WP_Error('bad_job_id', __('Invalid scan job ID', 'allaccessible')); } $path = self::PLUGIN_API_PREFIX . '/scans/status?' . http_build_query(array( 'jobId' => $job_id, )); return $this->signed_app_request('GET', $path, null); } /** * Send a signed request to the AllAccessible App service. * * @param string $method GET / POST / PATCH * @param string $path Absolute path (begins with /) * @param array|object|null $body Body to send as JSON. Pass null for GET. * @param bool $_retry Internal — set on the post-401 retry. * @param array $opts ['blocking' => false] dispatches * fire-and-forget: no retry, no response. * @return array|WP_Error */ private function signed_app_request($method, $path, $body, $_retry = false, $opts = array()) { $secret = $this->get_plugin_secret(); if (empty($secret)) { return new WP_Error('no_plugin_secret', __('Plugin secret unavailable. Re-validate license to refresh credentials.', 'allaccessible')); } $account_id = get_option('aacb_accountID'); $canonical = get_option(self::PLUGIN_SECRET_CANON_OPT) ?: get_site_url(); $timestamp = (string) time(); $body_string = ($body === null) ? '' : wp_json_encode($body); $sign_path = strtok((string) $path, '?'); $payload = $method . "\n" . $sign_path . "\n" . $timestamp . "\n" . $body_string; $signature = hash_hmac('sha256', $payload, $secret); // Signs a fresh timestamp into an existing $args. remote_with_retry() // calls this before a retry so attempt 2 never replays a stale // X-AAcb-Timestamp (which the server rejects as 401, triggering a // needless credential refetch after what was only a network blip). $resign = function ($retry_args) use ($method, $sign_path, $body_string, $secret) { $ts = (string) time(); $retry_args['headers']['X-AAcb-Timestamp'] = $ts; $retry_args['headers']['X-AAcb-Signature'] = hash_hmac( 'sha256', $method . "\n" . $sign_path . "\n" . $ts . "\n" . $body_string, $secret ); return $retry_args; }; $args = array( 'method' => $method, 'timeout' => 15, 'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'User-Agent' => 'AllAccessible-WordPress/' . AACB_VERSION, 'X-AAcb-AccountID' => $account_id, 'X-AAcb-Timestamp' => $timestamp, 'X-AAcb-Signature' => $signature, 'X-AAcb-SiteUrl' => $canonical, 'X-AAcb-Platform' => self::PLATFORM_HEADER, ), ); if ($body !== null) { $args['body'] = $body_string; } if (!empty($opts['timeout'])) { $args['timeout'] = (int) $opts['timeout']; } if (isset($opts['blocking']) && $opts['blocking'] === false) { $args['blocking'] = false; $args['timeout'] = 5; // connection setup window only wp_remote_request(self::APP_BASE_URL . $path, $args); return array('dispatched' => true); } $response = $this->remote_with_retry(self::APP_BASE_URL . $path, $args, $resign); if (!$_retry && !is_wp_error($response) && wp_remote_retrieve_response_code($response) === 401) { $refetched = $this->fetch_plugin_secret(); if (!is_wp_error($refetched)) { return $this->signed_app_request($method, $path, $body, true); } } return $this->decode_json_response($response, $sign_path); } /** * Map the WordPress locale into the form the API expects. */ private function wp_locale() { $locale = function_exists('get_locale') ? get_locale() : 'en_US'; return sanitize_text_field($locale); } /** * Issue an HTTP request with one retry on TRANSIENT failures (connection * timeouts, 502/503/504). A single blip is logged non-reporting (info) and * retried after a short backoff; only a persistent failure falls through to * the caller's error reporting. Cuts Sentry noise from one-off cURL 28 / * 5xx against slow upstreams (WORDPRESS-PLUGIN-6/7/8) without hiding real * outages (two consecutive failures still report). */ private function remote_with_retry($url, $args, $resign = null) { $attempts = 2; $delay_ms = 300; $response = null; // Total wall-clock budget. Without this a 15s-timeout call became a // 30.3s call the moment retry was added, and admin pages that make // several sequential calls could exceed PHP-FPM's request timeout and // return a 502 instead of degrading gracefully. The retry gets whatever // is left of the budget, never a second full timeout. $budget = (float) ($args['timeout'] ?? 15); $started = microtime(true); for ($i = 1; $i <= $attempts; $i++) { if ($i > 1) { $remaining = $budget - (microtime(true) - $started); if ($remaining < 2) { return $response; // no time left; return the first failure } $args['timeout'] = (int) max(2, floor($remaining)); // Re-sign: the HMAC timestamp from attempt 1 is now seconds old // and can fall outside the server's freshness window, turning a // network blip into a 401 and an unnecessary credential refetch. if (is_callable($resign)) { $args = call_user_func($resign, $args); } } $response = wp_remote_request($url, $args); if (!$this->is_transient_failure($response, $args) || $i === $attempts) { return $response; } AllAccessible_Debug::info('ApiClient::retry', 'Transient API failure — retrying', array( 'attempt' => $i, 'reason' => $this->failure_reason($response), )); usleep($delay_ms * 1000); $delay_ms *= 2; } return $response; } /** * Is this HTTP method safe to replay? * * A 502/503/504 can come back from a gateway AFTER the origin already * processed the request, so replaying a write can start a second scan * (burning metered quota) or double-apply an approve/revert. Only replay * reads; a write is retried solely when the failure proves the request * never landed (connection timeout / reset). */ private function is_idempotent_method($args) { $method = strtoupper((string) ($args['method'] ?? 'GET')); return in_array($method, array('GET', 'HEAD', 'OPTIONS'), true); } /** * Is this response a transient failure worth one retry? * cURL 28 (timeout), cURL 7 (connect failed), connection reset, or 502/503/504. */ private function is_transient_failure($response, $args = array()) { if (is_wp_error($response)) { $msg = $response->get_error_message(); return strpos($msg, 'cURL error 28') !== false || stripos($msg, 'timed out') !== false || strpos($msg, 'cURL error 7') !== false || stripos($msg, 'connection reset') !== false; } // A gateway 5xx may mean the write already succeeded upstream — only // replay it for reads. See is_idempotent_method(). if (!empty($args) && !$this->is_idempotent_method($args)) { return false; } $code = (int) wp_remote_retrieve_response_code($response); return in_array($code, array(502, 503, 504), true); } private function failure_reason($response) { return is_wp_error($response) ? $response->get_error_message() : ('HTTP ' . wp_remote_retrieve_response_code($response)); } /** * Shared response decoder. */ private function decode_json_response($response, $endpoint = '') { if (is_wp_error($response)) { // Transient connectivity (timeout / cURL 28 / connection reset) is // expected background noise on a hot path — log at warn for rate // visibility, not error. Genuinely unexpected transport failures // still go to error. $endpoint rides along so Sentry can say WHICH // API path timed out (WORDPRESS-PLUGIN-N was unattributable without it). $err_code = $response->get_error_code(); $err_msg = $response->get_error_message(); $transient = ($err_code === 'http_request_failed') || strpos($err_msg, 'Operation timed out') !== false || strpos($err_msg, 'timed out') !== false || strpos($err_msg, 'Connection reset') !== false || strpos($err_msg, 'Could not resolve host') !== false; if ($transient) { AllAccessible_Debug::warn('ApiClient::transport', $err_msg, array('endpoint' => $endpoint)); } else { AllAccessible_Debug::error('ApiClient::transport', $response, array('endpoint' => $endpoint)); } return new WP_Error('api_request_failed', sprintf( /* translators: %s: error message */ __('API request failed: %s', 'allaccessible'), $response->get_error_message() )); } $code = wp_remote_retrieve_response_code($response); $body = wp_remote_retrieve_body($response); $decoded = json_decode($body, true); if ($code !== 200) { $msg = is_array($decoded) && isset($decoded['error']) ? $decoded['error'] : ('HTTP ' . $code); $surface = 'ApiClient::http_' . $code; $payload = array('http_status' => $code, 'body' => $decoded, 'endpoint' => $endpoint); if ($code >= 500) { AllAccessible_Debug::error($surface, $msg, $payload); } elseif ($code === 401 || $code === 429) { AllAccessible_Debug::warn($surface, $msg, $payload); } return new WP_Error('api_error_' . $code, $msg, array('status' => $code, 'body' => $decoded)); } if (json_last_error() !== JSON_ERROR_NONE) { AllAccessible_Debug::error('ApiClient::json_decode', json_last_error_msg(), array( 'body_head' => substr((string) $body, 0, 200), )); return new WP_Error('json_decode_error', __('Failed to decode API response', 'allaccessible')); } return $decoded; } } // Initialize the API client AllAccessible_ApiClient::get_instance();