*/ private array $remote_map_memo = array(); /** * Per-request memo of the plan feature flags. * * Necessary because failures are deliberately not cached in a transient, * so without this a list of maps would make one failing request per row — * exactly when Mapsemble is unreachable and each one is paying a timeout. * * @var array|null */ private ?array $feature_flags_memo = null; /** * Last human-readable error, for display by admin screens. * * @var string */ private string $last_error = ''; /** * Returns the saved plugin settings, constants taking precedence. * * @return array{client_id: string, client_secret: string, delete_data_on_uninstall: bool} */ public function get_settings(): array { $saved = get_option( 'mapsemble_settings', array() ); if ( ! is_array( $saved ) ) { $saved = array(); } return array( 'client_id' => defined( 'MAPSEMBLE_CLIENT_ID' ) ? (string) MAPSEMBLE_CLIENT_ID : (string) ( $saved['client_id'] ?? '' ), 'client_secret' => defined( 'MAPSEMBLE_CLIENT_SECRET' ) ? (string) MAPSEMBLE_CLIENT_SECRET : (string) ( $saved['client_secret'] ?? '' ), 'delete_data_on_uninstall' => ! empty( $saved['delete_data_on_uninstall'] ), ); } /** * TRUE when both client_id and client_secret are set. */ public function has_credentials(): bool { $settings = $this->get_settings(); return '' !== $settings['client_id'] && '' !== $settings['client_secret']; } /** * Last error message from a failed API call, if any. */ public function get_last_error(): string { return $this->last_error; } /** * Discards the per-request memos: map details and plan flags. * * Same contract as MapConfigRepository::flush(): a real site is one * process per request, a test run is one process for dozens of them. */ public function flush(): void { $this->remote_map_memo = array(); $this->feature_flags_memo = null; } /** * Resolves the Mapsemble app base URL. */ public function get_app_base(): string { if ( defined( 'MAPSEMBLE_APP_BASE' ) && '' !== MAPSEMBLE_APP_BASE ) { $base = (string) MAPSEMBLE_APP_BASE; } else { $base = self::DEFAULT_APP_BASE; } /** * Filters the Mapsemble app base URL. * * @param string $base The app base URL, no trailing slash. */ return rtrim( (string) apply_filters( 'mapsemble_app_base', $base ), '/' ); } /** * The link that opens a map in the Mapsemble builder. * * For a person's browser, not for this client: nothing is fetched. * * @param string $map_id Remote Mapsemble map id. * @param string $panel Builder panel to open (`filters`, `configure`, * …); '' for the map as the builder opens it. * An unknown name just opens the editor. */ public function builder_url( string $map_id, string $panel = '' ): string { $url = $this->get_app_base() . '/map/' . rawurlencode( $map_id ); return '' === $panel ? $url : $url . '?panel=' . rawurlencode( $panel ); } /** * Returns the public URL Mapsemble should fetch features from. * * The MAPSEMBLE_REMOTE_BASE_URL constant (or filter) overrides the * site URL for local development, where the Mapsemble service * cannot reach hosts like *.ddev.site (use a tunnel URL instead). */ public function get_public_base_url(): string { $base = defined( 'MAPSEMBLE_REMOTE_BASE_URL' ) && '' !== MAPSEMBLE_REMOTE_BASE_URL ? (string) MAPSEMBLE_REMOTE_BASE_URL : home_url(); /** * Filters the public base URL used in Mapsemble-facing URLs. * * @param string $base The base URL, no trailing slash. */ return rtrim( (string) apply_filters( 'mapsemble_remote_base_url', $base ), '/' ); } /** * Returns a REST route's URL as the Mapsemble service should fetch it. * * Built from rest_url() rather than by gluing '/wp-json' onto the base, so * it stays correct on sites with plain permalinks (`?rest_route=`) or a * renamed REST prefix. Only the origin is swapped, and only when a public * base override is actually in force — otherwise a subdirectory install * would have its directory glued on twice. * * @param string $route Route relative to the REST root, e.g. `ns/v1/thing`. */ public function public_rest_url( string $route ): string { $url = rest_url( $route ); $base = $this->get_public_base_url(); if ( untrailingslashit( home_url() ) === $base ) { return $url; } $parts = wp_parse_url( $url ); if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return $url; } $origin = $parts['scheme'] . '://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); return $base . substr( $url, strlen( $origin ) ); } /** * Heuristic: can the Mapsemble service not reach this site? * * True when the site runs on a local dev domain or a private network * while Mapsemble does not: a local Mapsemble shares the dev network (DDEV * routes *.ddev.site between projects) and can fetch the feed, so only a * non-local one is locked out. Loopback is stricter: a site on localhost * is only reachable from that same localhost — a Mapsemble in another * container has its own. A public base override means a tunnel is in * place. */ public function looks_like_local_host(): bool { if ( defined( 'MAPSEMBLE_REMOTE_BASE_URL' ) && '' !== MAPSEMBLE_REMOTE_BASE_URL ) { return false; } $site = self::host_of( home_url() ); $app = self::host_of( $this->get_app_base() ); if ( self::is_loopback( $site ) ) { return ! self::is_loopback( $app ); } return self::is_local( $site ) && ! self::is_local( $app ); } /** * A URL's host, lower-cased and with IPv6 brackets stripped. * * @param string $url Absolute URL. */ private static function host_of( string $url ): string { return trim( strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ), '[]' ); } /** * Does a host name point at the machine it is resolved on? * * @param string $host Host as host_of() gives it. */ private static function is_loopback( string $host ): bool { return 'localhost' === $host || '::1' === $host || ( false !== filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && str_starts_with( $host, '127.' ) ); } /** * Does a host belong to a local dev domain or a private network? * * IP literals count when they are private or reserved (10/8, 192.168/16, * 127/8, fc00::/7, …); names by their dev-only suffixes. * * @param string $host Host as host_of() gives it. */ private static function is_local( string $host ): bool { if ( self::is_loopback( $host ) ) { return true; } // Private or reserved: 0.0.0.0 and 169.254/16 land here too. if ( false !== filter_var( $host, FILTER_VALIDATE_IP ) ) { return false === filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } $suffixes = array( '.ddev.site', '.lndo.site', '.test', '.local', '.localhost', '.internal', '.lan', '.home.arpa' ); foreach ( $suffixes as $suffix ) { if ( str_ends_with( $host, $suffix ) ) { return true; } } return false; } /** * Returns a valid access token, refreshing if needed. * * @return string The token, or '' on failure (see get_last_error()). */ public function get_token(): string { $cached = get_transient( self::TOKEN_TRANSIENT ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } // A recent exchange already failed; answer from the marker without // touching the network. Unlike the plan flags — where a cached failure // would pin a wrong notice — a cached token failure reproduces exactly // what a retry would say, minus the timeout. Only *failed exchanges* // arm this, so the stale-token retry in request(), which discards the // token and re-exchanges with credentials that still work, is never // suppressed by it. $failed = get_transient( self::TOKEN_FAILURE_TRANSIENT ); if ( is_string( $failed ) && '' !== $failed ) { $this->last_error = $failed; return ''; } // Same shape for a rate limit: Mapsemble asked for a pause, and the // token endpoint counts towards it like any other. $backoff = $this->backoff_message(); if ( '' !== $backoff ) { $this->last_error = $backoff; return ''; } $settings = $this->get_settings(); if ( '' === $settings['client_id'] || '' === $settings['client_secret'] ) { return ''; } $response = wp_remote_post( $this->get_app_base() . '/token', array( 'timeout' => 15, 'sslverify' => $this->ssl_verify(), 'headers' => array( 'Accept' => 'application/json' ), 'body' => array( 'grant_type' => 'client_credentials_with_user', 'client_id' => $settings['client_id'], 'client_secret' => $settings['client_secret'], ), ) ); if ( ! is_wp_error( $response ) && 429 === (int) wp_remote_retrieve_response_code( $response ) ) { // Not a failed exchange: the credentials were never judged, so // the failure marker stays down and the backoff alone gates // the retry. $this->enter_backoff( $response ); return ''; } // The failure class picks the advice. Only a rejection may blame the // client ID and secret; a transport failure or a 5xx means Mapsemble // could not answer at all, and "check your credentials" during an // outage sends people rotating secrets that were fine all along. The // chosen copy flows through request()'s detail passthrough and the // failure marker automatically, because both carry last_error // verbatim — "try again in a minute" is the marker's own window. $unreachable = is_wp_error( $response ) || (int) wp_remote_retrieve_response_code( $response ) >= 500; $message = $unreachable ? __( 'Could not reach Mapsemble. Check your connection and try again in a minute.', 'mapsemble' ) : __( 'Could not authenticate with Mapsemble. Check your client ID and secret.', 'mapsemble' ); $data = $this->decode_response( $response, $message ); $token = (string) ( $data['access_token'] ?? '' ); $expires_in = (int) ( $data['expires_in'] ?? 0 ); if ( '' === $token || $expires_in <= 0 ) { delete_transient( self::TOKEN_TRANSIENT ); if ( '' === $this->last_error ) { // A 2xx answer missing its fields: reached, so $message is // still the credential copy here. $this->fail( 'Token response missing fields.', $message ); } // Remember the failure — message included, so suppressed calls // still surface the credential hint — for TOKEN_FAILURE_TTL. set_transient( self::TOKEN_FAILURE_TRANSIENT, $this->last_error, self::TOKEN_FAILURE_TTL ); return ''; } // A success voids any failure marker a concurrent request armed while // this exchange was in flight — a working connection must not sit out // the rest of the window. delete_transient( self::TOKEN_FAILURE_TRANSIENT ); // 60 second margin so we don't hand out a token about to expire. set_transient( self::TOKEN_TRANSIENT, $token, max( 1, $expires_in - 60 ) ); return $token; } /** * Lists remote maps from Mapsemble, cached briefly. * * @param bool $force_refresh Skip and replace the cached listing. * @return array Keyed by remote map id. Empty on failure. */ public function get_remote_maps( bool $force_refresh = false ): array { if ( ! $this->has_credentials() ) { return array(); } if ( ! $force_refresh ) { $cached = get_transient( self::MAPS_TRANSIENT ); if ( is_array( $cached ) ) { return $cached; } } $data = $this->authorized_get( self::MAPS_ENDPOINT, __( 'Could not retrieve maps from Mapsemble.', 'mapsemble' ) ); if ( null === $data ) { return array(); } $maps = array(); foreach ( $data as $map ) { if ( is_array( $map ) && isset( $map['id'] ) ) { $maps[ (string) $map['id'] ] = $map; } } set_transient( self::MAPS_TRANSIENT, $maps, self::MAPS_CACHE_TTL ); return $maps; } /** * Fetches a single remote map (with fields, filters, config). * * The answer is deliberately three-way rather than "the map, or []". A map * that no longer exists is a routine, self-inflicted condition — it was * deleted, or the credentials now point at a different account — and it is * fixed from the map select on the very screen that asks. An unreachable * Mapsemble is not fixed from that screen at all. Folding both into [] * is what would make a stale binding read as an outage, sending people to * check their network over a dropdown. * * Callers that only need "is there usable data here" can treat both * failures as falsy, which is what the save-time mirror does. * * @param string $map_id Remote Mapsemble map id. * @return array|null The decoded map on success; `[]` when Mapsemble * answered that no such map exists (404), or the id * is not one at all; `null` when the question could * not be put to Mapsemble at all. */ public function get_remote_map( string $map_id ): ?array { if ( '' === $map_id ) { return null; } if ( ! MapId::is_valid( $map_id ) ) { // Settled without asking: no map has an id of this shape. $this->last_error = $this->invalid_map_id_message(); return array(); } if ( array_key_exists( $map_id, $this->remote_map_memo ) ) { return $this->remote_map_memo[ $map_id ]; } if ( ! $this->has_credentials() ) { $this->remote_map_memo[ $map_id ] = null; return null; } $result = $this->request( 'GET', self::MAPS_ENDPOINT . '/' . rawurlencode( $map_id ) ); $data = json_decode( $result['body'], true ); if ( $this->is_success( $result['status'] ) && is_array( $data ) && ! empty( $data['id'] ) ) { $this->last_error = ''; $this->remote_map_memo[ $map_id ] = $data; return $data; } $this->fail( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'], 404 === $result['status'] ? __( 'That map no longer exists on the connected Mapsemble account.', 'mapsemble' ) : __( 'Could not load map details from Mapsemble.', 'mapsemble' ) ); // Only 404 is an answer about the map. Every other status, and every // failure that never reached a status, leaves the question open. $answer = 404 === $result['status'] ? array() : null; $this->remote_map_memo[ $map_id ] = $answer; return $answer; } /** * One of a remote map's catalogs — `filters` or `orderBy` — keyed by * slug, in Mapsemble's own order (by weight). Entries without a slug * are dropped: there is nothing to bind or filter by. * * @param array|null $remote A get_remote_map() answer; null and [] give []. * @param string $key Catalog key in the map payload. * @return array Entries keyed by slug. */ public static function catalog( ?array $remote, string $key ): array { $catalog = array(); foreach ( (array) ( $remote[ $key ] ?? array() ) as $entry ) { if ( is_array( $entry ) && '' !== (string) ( $entry['slug'] ?? '' ) ) { $catalog[ (string) $entry['slug'] ] = $entry; } } uasort( $catalog, static fn( array $a, array $b ): int => (int) ( $a['weight'] ?? 0 ) <=> (int) ( $b['weight'] ?? 0 ) ); return $catalog; } /** * Creates a new Mapsemble map. * * The map comes back in Mapsemble's own (hosted) shape. Pointing it at a * WordPress feed is a separate, explicit step — see patch_data_source() — * so that the same path serves both "create as hosted" and a later switch * to WordPress mode. * * @param string $label Human-readable label for the new map. * @param string $starter_kit On-disk kit slug. `default` imports a bare * map and does no wiring of its own; it is * marked internal on the Mapsemble side but the * catalog still resolves it by name. * @return array The created map (with `id`), or [] on failure. */ public function create_map( string $label, string $starter_kit = 'default' ): array { $label = trim( $label ); if ( '' === $label ) { $this->last_error = __( 'A name is required to create a map.', 'mapsemble' ); return array(); } if ( ! $this->has_credentials() ) { $this->last_error = __( 'Mapsemble credentials are not configured.', 'mapsemble' ); return array(); } $result = $this->request( 'POST', self::MAPS_ENDPOINT, array( 'starterKit' => $starter_kit, 'label' => $label, 'baseUrl' => $this->get_public_base_url(), ) ); $data = json_decode( $result['body'], true ); if ( ! $this->is_success( $result['status'] ) || ! is_array( $data ) || empty( $data['id'] ) ) { $this->fail( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'], __( 'Could not create the map on Mapsemble.', 'mapsemble' ) ); return array(); } // The new map is now visible — drop the cached listing so the select // includes it on the next render. delete_transient( self::MAPS_TRANSIENT ); $this->last_error = ''; return $data; } /** * Points a remote map's data source at a mode, and optionally a URL. * * @param string $map_id Remote Mapsemble map id. * @param string $mode `local` (hosted) or `remote` (fed from here). * @param string|null $url Feed URL for a remote source, or null to * leave the stored URL untouched. Null is not * "clear it": a map switched to hosted keeps * its feed URL so switching back is lossless. * @return bool True when the remote map was updated. False on any failure, * with the error recorded — callers use this to abort before * WordPress and Mapsemble can end up disagreeing about where * a map's locations live. */ public function patch_data_source( string $map_id, string $mode, ?string $url = null ): bool { if ( '' === $map_id || ! $this->has_credentials() ) { return false; } if ( ! MapId::is_valid( $map_id ) ) { $this->last_error = $this->invalid_map_id_message(); return false; } $data_source = array( 'mode' => $mode ); if ( null !== $url ) { $data_source['url'] = $url; } $result = $this->request( 'PATCH', self::MAPS_ENDPOINT . '/' . rawurlencode( $map_id ), array( 'dataSource' => $data_source ) ); if ( ! $this->is_success( $result['status'] ) ) { $this->fail( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'], __( 'Could not update the data source on Mapsemble.', 'mapsemble' ) ); return false; } // The cached detail is now stale. unset( $this->remote_map_memo[ $map_id ] ); $this->last_error = ''; return true; } /** * Mints a single-use editor nonce scoped to one map. * * @param string $map_id Remote Mapsemble map id to scope the nonce to. * @return string The JWT, or '' on failure (see get_last_error()). */ public function create_editor_nonce( string $map_id ): string { if ( '' === $map_id || ! $this->has_credentials() ) { return ''; } if ( ! MapId::is_valid( $map_id ) ) { $this->last_error = $this->invalid_map_id_message(); return ''; } $result = $this->request( 'POST', self::NONCE_ENDPOINT, array( 'map' => $map_id ) ); // The response body is a bare JWT, not JSON — deliberately not run // through decode_response(). $nonce = trim( $result['body'] ); if ( ! $this->is_success( $result['status'] ) || '' === $nonce ) { $this->fail( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'], __( 'Could not open an editing session on Mapsemble.', 'mapsemble' ) ); return ''; } $this->last_error = ''; return $nonce; } /** * Geocodes an address through Mapsemble's metered geocoder. * * Deliberately exempt from the plugin-wide 429 backoff: the geocoder's * limits are per feature (a monthly quota, and a per-minute and per-day * rate), so arming the shared marker on them would let one editor busy * searching addresses lock every admin screen out of Mapsemble. The * reverse still holds — a backoff Mapsemble asked for elsewhere keeps * the geocoder quiet too, and reads as unavailable. * * @param string $query Address as typed. * @return array{lat: float, lng: float}|array{error: 'not_found'|'not_allowed'|'quota'|'rate_limited'|'too_long'|'unavailable', retry_after?: int} */ public function geocode( string $query ): array { $unavailable = array( 'error' => 'unavailable' ); if ( ! $this->has_credentials() ) { return $unavailable; } // add_query_arg() does not encode values itself, so this is the one // and only encoding the query gets. $result = $this->request( 'GET', add_query_arg( 'q', rawurlencode( $query ), self::GEOCODE_ENDPOINT ), null, true ); $data = json_decode( $result['body'], true ); $reason = is_array( $data ) && is_string( $data['error'] ?? null ) ? $data['error'] : ''; switch ( $result['status'] ) { case 200: if ( is_array( $data ) && is_numeric( $data['lat'] ?? null ) && is_numeric( $data['lng'] ?? null ) ) { $this->last_error = ''; return array( 'lat' => (float) $data['lat'], 'lng' => (float) $data['lng'], ); } break; case 404: // Only the geocoder's own "no match" is one. A bare 404 is a // Mapsemble without the route, where every address would // otherwise read as unknown. if ( 'not_found' === $reason ) { return array( 'error' => 'not_found' ); } break; case 403: return array( 'error' => 'not_allowed' ); case 400: if ( 'query_too_long' === $reason ) { return array( 'error' => 'too_long' ); } break; case 429: if ( 'quota_exceeded' === $reason ) { return array( 'error' => 'quota' ); } return array( 'error' => 'rate_limited', 'retry_after' => max( 1, $result['retry_after'] ?? MINUTE_IN_SECONDS ), ); } $this->log( 'Geocode: ' . ( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'] ) ); return $unavailable; } /** * Drops everything cached about the connected account. * * Called whenever the credentials change, by the settings form and the * connect flow alike. The token authenticates as whichever account minted * it, the plan flags describe that account's subscription, the maps list * is that account's, and the failure marker remembers an exchange the * *old* credentials lost — keeping it would make the new ones sit out * the rest of its window. Static because callers hold no client at the * moment they need it (uninstall, tests) and it touches no state of one. */ public static function forget_account_caches(): void { delete_transient( self::TOKEN_TRANSIENT ); delete_transient( self::MAPS_TRANSIENT ); delete_transient( self::FEATURE_FLAGS_TRANSIENT ); delete_transient( self::TOKEN_FAILURE_TRANSIENT ); delete_transient( self::BACKOFF_TRANSIENT ); } /** * Collects the credentials an admin approved on Mapsemble, if any. * * Unauthenticated by nature — the site has no credentials yet, that is * the point — so this does not go through request(). The state is the * bearer: Mapsemble hands the credentials out exactly once for it. * * @param string $state The pending attempt's state. * @return array{client_id: string, client_secret: string}|null The * credentials, or null when nothing is waiting (a 202, the * ordinary answer mid-approval) or the request failed. */ public function collect_connected_credentials( string $state ): ?array { if ( '' === $state ) { return null; } $response = wp_remote_get( $this->get_app_base() . '/wordpress/connect/poll?state=' . rawurlencode( $state ), array( 'timeout' => 15, 'sslverify' => $this->ssl_verify(), 'headers' => array( 'Accept' => 'application/json' ), ) ); if ( is_wp_error( $response ) ) { $this->log( 'Connect poll failed: ' . $response->get_error_message() ); return null; } $status = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $status ) { if ( 202 !== $status ) { // 202 is "not approved yet"; anything else is a real failure // worth a line in the log. Still not surfaced as an error: // the caller says "not connected yet", which is what the // admin can act on. $this->log( 'Connect poll answered HTTP ' . $status ); } return null; } $data = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) || empty( $data['found'] ) ) { return null; } $client_id = (string) ( $data['client_id'] ?? '' ); $client_secret = (string) ( $data['client_secret'] ?? '' ); if ( '' === $client_id || '' === $client_secret ) { $this->log( 'Connect poll answered 200 without both credentials.' ); return null; } return array( 'client_id' => $client_id, 'client_secret' => $client_secret, ); } /** * Whether the connected account may feed maps from this site. * * @return bool|null True or false from the account's plan, or null when * unknown. Null means say nothing: telling a Pro * customer they need Pro is worse than silence. */ public function remote_source_allowed(): ?bool { $flags = $this->get_feature_flags(); return isset( $flags['remote_source'] ) ? (bool) $flags['remote_source'] : null; } /** * Returns the connected account's plan feature flags. * * Only successes are cached, and that is load-bearing: caching a failure * would pin "unknown" for the whole TTL, so one flaky request would * suppress the plan notice long after the outage ended. The other side of * that trade is that every call during an outage is a live HTTP request, * which is why the caller memoises rather than asking per list row. * * @return array The flags, or [] when undeterminable. */ private function get_feature_flags(): array { if ( null !== $this->feature_flags_memo ) { return $this->feature_flags_memo; } $cached = get_transient( self::FEATURE_FLAGS_TRANSIENT ); if ( is_array( $cached ) ) { $this->feature_flags_memo = $cached; return $cached; } if ( ! $this->has_credentials() ) { $this->feature_flags_memo = array(); return array(); } $result = $this->request( 'GET', self::ME_ENDPOINT ); $data = json_decode( $result['body'], true ); if ( ! $this->is_success( $result['status'] ) || ! is_array( $data ) ) { // Recorded for the debug log but not surfaced: the caller turns [] // into "unknown" and shows nothing. $this->log( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'] ); $this->feature_flags_memo = array(); return array(); } $flags = is_array( $data['featureFlags'] ?? null ) ? $data['featureFlags'] : array(); set_transient( self::FEATURE_FLAGS_TRANSIENT, $flags, self::FEATURE_FLAGS_TTL ); $this->feature_flags_memo = $flags; return $flags; } /** * GET an API path and decode its JSON body. * * @param string $path API path starting with '/'. * @param string $error_message User-facing message on failure. * @return array|null Decoded JSON array, or null on failure. */ private function authorized_get( string $path, string $error_message ): ?array { $result = $this->request( 'GET', $path ); $data = json_decode( $result['body'], true ); if ( ! $this->is_success( $result['status'] ) || ! is_array( $data ) ) { $this->fail( 0 === $result['status'] ? $result['detail'] : 'HTTP ' . $result['status'], $error_message ); return null; } $this->last_error = ''; return $data; } /** * Performs one authenticated request, retrying once on 401. * * A 429 — live, or remembered from an earlier one — is reported as * status 0 with the backoff message as the detail: to every caller it * is "not now", the same open question as an unreachable Mapsemble, and * the message is already the one they should show. * * A metered feature endpoint (the geocoder) speaks for itself instead: * its 429 is a limit on that feature, returned as-is with the seconds * from Retry-After, and its 403 is "not on your plan", which leaves the * token alone. A standing backoff still stops it before any HTTP. * * @param string $method HTTP method. * @param string $path API path starting with '/'. * @param array|null $json Request body, JSON-encoded when given. * @param bool $metered Whether the endpoint's 429 and 403 are about * the feature rather than the account. * @return array{status: int, body: string, detail: string, retry_after?: int|null} * `status` is 0 when the request never reached a response, with * the reason in `detail`. `retry_after` accompanies a metered 429. */ private function request( string $method, string $path, ?array $json = null, bool $metered = false ): array { // Before any HTTP, the cached token included: the token being valid // says nothing about whether Mapsemble wants to hear from us. Set as // last_error so fail() recognises the detail and keeps it verbatim. $backoff = $this->backoff_message(); if ( '' !== $backoff ) { $this->last_error = $backoff; return array( 'status' => 0, 'body' => '', 'detail' => $backoff, ); } foreach ( array( false, true ) as $is_retry ) { $token = $this->get_token(); if ( '' === $token ) { // get_token() already recorded why — and its message is the // actionable one ("check your client ID and secret"), where // "No access token." is only the symptom. Passing it through // as the detail lets fail() recognise and preserve it. This // leans on get_token() setting last_error on every failure // path it can reach from here: the one silent return — empty // credentials — is unreachable because every public caller // gates on has_credentials() first. return array( 'status' => 0, 'body' => '', 'detail' => '' !== $this->last_error ? $this->last_error : 'No access token.', ); } $args = array( 'method' => $method, 'timeout' => 15, 'sslverify' => $this->ssl_verify(), 'headers' => array( 'Accept' => 'application/json', 'Authorization' => 'Bearer ' . $token, ), ); if ( null !== $json ) { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $json ); } $response = wp_remote_request( $this->get_app_base() . $path, $args ); if ( is_wp_error( $response ) ) { return array( 'status' => 0, 'body' => '', 'detail' => $response->get_error_message(), ); } $status = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $status && ! $is_retry ) { // Stale token: discard and retry once with a fresh one. delete_transient( self::TOKEN_TRANSIENT ); continue; } if ( 429 === $status && $metered ) { return array( 'status' => 429, 'body' => (string) wp_remote_retrieve_body( $response ), 'detail' => '', 'retry_after' => $this->retry_after_seconds( $response ), ); } if ( 429 === $status ) { $this->enter_backoff( $response ); return array( 'status' => 0, 'body' => '', 'detail' => $this->last_error, ); } if ( ! ( $metered && 403 === $status ) ) { $this->clear_token_if_credential_rejected( $status ); } return array( 'status' => $status, 'body' => (string) wp_remote_retrieve_body( $response ), 'detail' => '', ); } return array( 'status' => 0, 'body' => '', 'detail' => 'Retry exhausted.', ); } /** * Why a malformed map id got no further than this client. * * Every method that puts a map id into a URL checks it first: the id is * a path segment, and rawurlencode() keeps the dots of `..`. */ private function invalid_map_id_message(): string { return __( 'That is not a Mapsemble map id.', 'mapsemble' ); } /** * The backoff marker's message, or '' when no backoff is in force. */ private function backoff_message(): string { $backoff = get_transient( self::BACKOFF_TRANSIENT ); return is_string( $backoff ) ? $backoff : ''; } /** * Arms the backoff marker from a 429 and records the failure. * * Retry-After may be seconds or an HTTP-date; either is clamped to * [1, BACKOFF_MAX_TTL]. Absent or unreadable, the default window applies. * * @param array $response The 429 response. */ private function enter_backoff( array $response ): void { $seconds = $this->retry_after_seconds( $response ); $ttl = null === $seconds ? self::BACKOFF_DEFAULT_TTL : max( 1, min( self::BACKOFF_MAX_TTL, $seconds ) ); $message = __( 'Mapsemble is rate-limiting requests from this site. Try again shortly.', 'mapsemble' ); set_transient( self::BACKOFF_TRANSIENT, $message, $ttl ); // Recorded directly, so the callers' fail() recognises the detail // and keeps it whole rather than wrapping it in "could not retrieve X". $this->last_error = $message; $this->log( 'HTTP 429, backing off for ' . $ttl . 's.' ); } /** * A response's Retry-After in seconds from now, unclamped. * * @param array $response The response. * @return int|null Seconds (possibly zero or negative for a date in the * past), or null when absent or unreadable. */ private function retry_after_seconds( array $response ): ?int { $header = wp_remote_retrieve_header( $response, 'retry-after' ); if ( is_array( $header ) ) { $header = (string) reset( $header ); } $header = trim( (string) $header ); if ( '' === $header ) { return null; } if ( ctype_digit( $header ) ) { return (int) $header; } $until = strtotime( $header ); return false === $until ? null : $until - time(); } /** * Drops the cached token, but only when the server rejected the credential. * * Deliberately narrow. Access tokens are issued with a very long TTL, so * the expiry check can never recover a credential revoked server-side, and * re-authenticating on 401/403 is the only route back. Everything else is * our own fault rather than the credential's: a 422 from * patch_data_source() is a URL we malformed, and a 404 is a map that does * not exist. Discarding a working token over either buys nothing and costs * an extra round trip on the very next call — which, for the data editor, * is once per failure. * * @param int $status HTTP status of the response just received. */ private function clear_token_if_credential_rejected( int $status ): void { if ( 401 === $status || 403 === $status ) { delete_transient( self::TOKEN_TRANSIENT ); } } /** * Whether outbound requests verify the app's TLS certificate. * * Always, unless MAPSEMBLE_SSL_VERIFY is defined and falsy. The opt-out * exists for local DDEV stacks, whose self-signed certificate the * WordPress container does not trust; it is deliberately separate from * MAPSEMBLE_APP_BASE so that pointing the plugin at a self-hosted * Mapsemble does not silently switch verification off. */ private function ssl_verify(): bool { return ! defined( 'MAPSEMBLE_SSL_VERIFY' ) || (bool) MAPSEMBLE_SSL_VERIFY; } /** * Whether an HTTP status is a 2xx. * * @param int $status HTTP status code. */ private function is_success( int $status ): bool { return $status >= 200 && $status < 300; } /** * Validates and decodes an HTTP API response. * * @param array|\WP_Error $response wp_remote_* response. * @param string $error_message User-facing message on failure. * @return array Decoded JSON, or [] on failure (last_error set). */ private function decode_response( $response, string $error_message ): array { if ( is_wp_error( $response ) ) { $this->fail( $response->get_error_message(), $error_message ); return array(); } $code = wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { $this->fail( 'HTTP ' . $code, $error_message ); return array(); } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { $this->fail( 'Response is not valid JSON.', $error_message ); return array(); } $this->last_error = ''; return $data; } /** * Records a failure for the admin UI and debug log. * * A detail that already *is* the current last_error is a failure being * passed back through request() — the token exchange's "check your client * ID and secret". That message names the fix; the caller's wrapper only * names the symptom ("could not retrieve maps"), so wrapping would bury * the actionable message inside parentheses. Keep it and only log. * * @param string $detail Technical detail. * @param string $user_message User-facing message. */ private function fail( string $detail, string $user_message ): void { if ( '' !== $detail && $detail === $this->last_error ) { $this->log( $detail ); return; } $this->last_error = $user_message . ' (' . $detail . ')'; $this->log( $detail ); } /** * Records a technical detail in the debug log without surfacing it. * * Used where the caller turns a failure into silence — the plan flags, * where a wrong notice is worse than none — so that the administrator * still has the reason available. * * @param string $detail Technical detail. */ private function log( string $detail ): void { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( 'Mapsemble API: ' . $detail ); } } }