` header. The key is read from the * encrypted option; never logged, never exposed to the client. * * Responses are cached in WordPress transients where it makes sense * (catalog, per-plugin update probe). */ class Robotstxt_Manager_Core_Client { /** * REST namespace on the remote Core install. * * @var non-falsy-string */ private const REST_NAMESPACE = 'robotstxt-core/v1'; /** * Default request timeout in seconds. * * @var int */ private const TIMEOUT = 15; /** * Transient TTL for the catalog cache (seconds). Default 1 hour. * * @var int */ public const CATALOG_TTL_DEFAULT = HOUR_IN_SECONDS; /** * Base URL of the remote Core install (no trailing slash). * * @var string */ private string $store_url; /** * Account-level API key (plaintext, never logged). * * @var string */ private string $api_key; /** * Constructor. * * @param string $store_url Base URL of the remote Plugins Core install. * @param string $api_key Account-level API key (plaintext). */ public function __construct( string $store_url, string $api_key ) { $this->store_url = rtrim( $store_url, '/' ); $this->api_key = $api_key; } /** * Builds a client configured from the saved plugin options. * * @return self */ public static function from_options(): self { $store_url_raw = get_site_option( 'robotstxt_manager_store_url', '' ); $api_key_raw = get_site_option( 'robotstxt_manager_api_key', '' ); $store_url = is_string( $store_url_raw ) ? $store_url_raw : ''; $api_key = is_string( $api_key_raw ) ? Robotstxt_Manager_Encryption::decrypt( $api_key_raw ) : ''; return new self( $store_url, $api_key ); } /** * Returns whether the client can talk to the store. * * Only the Store URL is required: the catalog (free plugins, prices, * product pages) is public on Core. The API key is optional — it is * needed for subscription data and premium downloads, not for listing. * * @return bool */ public function is_configured(): bool { return '' !== $this->store_url; } /** * Returns whether an API key is configured. * * @return bool */ public function has_api_key(): bool { return '' !== $this->api_key; } /** * Returns the configured store URL. * * @return string */ public function get_store_url(): string { return $this->store_url; } /** * Tests connectivity to the remote Core install. * * Performs a lightweight authenticated GET against the catalog endpoint. * Used by the Settings screen "Test connection" button and by the * pre-save validation in Robotstxt_Manager_Settings::sanitize_api_key(). * * @return array{ * ok:bool, * message:string, * catalog_count?:int, * } */ public function test_connection(): array { if ( '' === $this->store_url ) { return array( 'ok' => false, 'message' => __( 'Store URL is not configured.', 'robotstxt-manager' ), ); } if ( '' === $this->api_key ) { return array( 'ok' => false, 'message' => __( 'API key is not configured.', 'robotstxt-manager' ), ); } $response = $this->get( '/me/subscriptions' ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, /* translators: %s: HTTP transport error message. */ 'message' => sprintf( __( 'Could not reach Plugins Core: %s', 'robotstxt-manager' ), $response->get_error_message() ), ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $code ) { return array( 'ok' => false, 'message' => __( 'Invalid API key. Please check your key and try again.', 'robotstxt-manager' ), ); } if ( 200 !== $code ) { return array( 'ok' => false, /* translators: %d: HTTP status code. */ 'message' => sprintf( __( 'The store responded with HTTP %d. Check the Store URL and API key.', 'robotstxt-manager' ), $code ), ); } $decoded = json_decode( wp_remote_retrieve_body( $response ), true ); $count = is_array( $decoded ) ? count( $decoded ) : 0; return array( 'ok' => true, 'message' => __( 'Connected.', 'robotstxt-manager' ), 'subscriptions' => $count, ); } /** * Returns the full plugin catalog from Core, cached in a transient. * * @return list> Catalog entries (slug, name, type, price, etc.). */ public function get_catalog(): array { if ( ! $this->is_configured() ) { return array(); } $cache_key = 'robotstxt_manager_catalog'; $cached = get_site_transient( $cache_key ); if ( is_array( $cached ) ) { $typed = array(); foreach ( $cached as $entry ) { if ( is_array( $entry ) ) { $row = array(); foreach ( $entry as $k => $v ) { if ( is_string( $k ) ) { $row[ $k ] = $v; } } $typed[] = $row; } } return $typed; } $response = $this->get( '/plugins' ); if ( is_wp_error( $response ) ) { return array(); } // A non-200 response (auth failure, outage) must not be cached as an // "empty catalog" for the full TTL — return nothing and retry next time. if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return array(); } $body = wp_remote_retrieve_body( $response ); $data = json_decode( $body, true ); if ( ! is_array( $data ) ) { return array(); } $catalog = array(); foreach ( $data as $entry ) { if ( is_array( $entry ) ) { $row = array(); foreach ( $entry as $k => $v ) { if ( is_string( $k ) ) { $row[ $k ] = $v; } } $catalog[] = $row; } } $ttl = $this->get_catalog_ttl(); set_site_transient( $cache_key, $catalog, $ttl ); return $catalog; } /** * Clears the cached catalog response. Called when settings change or * when the admin user clicks "Refresh" on the catalog screen. * * @return void */ public function clear_catalog_cache(): void { delete_site_transient( 'robotstxt_manager_catalog' ); } /** * Returns the configured catalog-cache TTL in seconds. * * @return int */ private function get_catalog_ttl(): int { $raw = get_site_option( 'robotstxt_manager_cache_ttl_minutes', 60 ); $min = is_numeric( $raw ) ? (int) $raw : 60; if ( $min < 1 ) { return self::CATALOG_TTL_DEFAULT; } return $min * MINUTE_IN_SECONDS; } /** * Performs an authenticated GET request to the Core REST API. * * @param string $endpoint Path relative to the REST namespace (e.g. '/plugins'). * * @return WP_Error|array WP_Error on failure, or the wp_remote_get response array on success. */ private function get( string $endpoint ) { $url = $this->store_url . '/wp-json/' . self::REST_NAMESPACE . $endpoint; $headers = array( 'Accept' => 'application/json' ); if ( '' !== $this->api_key ) { $headers['Authorization'] = 'Bearer ' . $this->api_key; } return wp_remote_get( $url, array( 'headers' => $headers, 'timeout' => self::TIMEOUT, ) ); } /** * Returns the account's subscriptions (Core's /me/subscriptions), * cached in a transient for one hour. Keys: plugin_slug => row. * * @return array> Rows keyed by plugin slug. */ public function get_subscriptions(): array { if ( ! $this->is_configured() ) { return array(); } $cache_key = 'robotstxt_manager_subscriptions'; $cached = get_site_transient( $cache_key ); if ( is_array( $cached ) ) { $typed = array(); foreach ( $cached as $slug => $row ) { if ( is_string( $slug ) && is_array( $row ) ) { $typed_row = array(); foreach ( $row as $k => $v ) { if ( is_string( $k ) ) { $typed_row[ $k ] = $v; } } $typed[ $slug ] = $typed_row; } } return $typed; } $response = $this->get( '/me/subscriptions' ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return array(); // Not cached — retried on the next view. } $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! is_array( $data ) ) { return array(); } $rows = array(); foreach ( $data as $row ) { if ( ! is_array( $row ) ) { continue; } $slug = $row['plugin_slug'] ?? ''; $slug = is_string( $slug ) ? $slug : ''; if ( '' === $slug ) { continue; } $raw_status = $row['status'] ?? ''; $raw_expires_at = $row['expires_at'] ?? ''; $raw_bound = $row['bound_domain'] ?? ''; $status = is_string( $raw_status ) ? $raw_status : ''; $expires_at = is_string( $raw_expires_at ) ? $raw_expires_at : ''; $bound = is_string( $raw_bound ) ? $raw_bound : ''; // Group multi-license rows (Core 1.14.0+: one row per domain) // into one entry per slug: any-active wins, first bound domain // shown, license count kept for the pill. if ( isset( $rows[ $slug ] ) ) { $existing = $rows[ $slug ]; if ( 'active' === $status && 'active' !== $existing['status'] ) { $existing['status'] = 'active'; $existing['expires_at'] = $expires_at; } if ( '' === $existing['bound_domain'] && '' !== $bound ) { $existing['bound_domain'] = $bound; } $existing['license_count'] = (int) $existing['license_count'] + 1; $rows[ $slug ] = $existing; continue; } $rows[ $slug ] = array( 'status' => $status, 'expires_at' => $expires_at, 'bound_domain' => $bound, 'license_count' => 1, ); } set_site_transient( $cache_key, $rows, HOUR_IN_SECONDS ); return $rows; } /** * Exchanges the account API key for a short-lived download token * (Core 1.9.0+ `POST /me/download-token`). * * Tokens are cached in a short-TTL site transient (5 minutes, a fraction * of the 15-minute token lifetime) because the updater rebuilds package * URLs on every read of the update_plugins transient. Failed exchanges * are negatively cached for one minute so a slow or down store is not * queried on every read either. * * @param string $slug Plugin slug the token may download. * * @return string Token string, or '' when unavailable (older Core, no * key, inactive subscription, or transport error — * callers fall back to the API-key flow). */ public function exchange_download_token( string $slug ): string { if ( ! $this->has_api_key() ) { return ''; } $cache_key = 'robotstxt_manager_dl_token_' . sanitize_key( $slug ); $cached = get_site_transient( $cache_key ); if ( is_string( $cached ) ) { return $cached; // Token, or '' from a negatively cached failure. } // Send this site's domain so per-domain license binding is enforced // at token issuance (Core 1.11.0+); older Core ignores the field. $host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $domain = (string) preg_replace( '/^www\./', '', $host ); $response = wp_remote_post( $this->store_url . '/wp-json/' . self::REST_NAMESPACE . '/me/download-token', array( 'headers' => array( 'Authorization' => 'Bearer ' . $this->api_key, 'Accept' => 'application/json', 'Content-Type' => 'application/json', ), 'body' => (string) wp_json_encode( array( 'slug' => $slug, 'domain' => $domain, ) ), 'timeout' => self::TIMEOUT, ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { set_site_transient( $cache_key, '', MINUTE_IN_SECONDS ); return ''; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); $token = is_array( $data ) ? ( $data['token'] ?? '' ) : ''; $token = is_string( $token ) ? $token : ''; if ( '' === $token ) { set_site_transient( $cache_key, '', MINUTE_IN_SECONDS ); return ''; } set_site_transient( $cache_key, $token, 5 * MINUTE_IN_SECONDS ); return $token; } /** * Clears the cached subscriptions response (key change, manual refresh). * * @return void */ public function clear_subscriptions_cache(): void { delete_site_transient( 'robotstxt_manager_subscriptions' ); } }