418 lines
10 KiB
PHP
418 lines
10 KiB
PHP
<?php
|
|
/**
|
|
* HTTP client for the remote Plugins Core REST API.
|
|
*
|
|
* @package Robotstxt_Manager
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class Robotstxt_Manager_Core_Client
|
|
*
|
|
* Wraps the small subset of Plugins Core's REST API that Manager needs:
|
|
* - GET /plugins (catalog)
|
|
* - GET /plugins/{slug} (single plugin info, future phase)
|
|
* - GET /plugins/{slug}/update-check (per-installed-plugin update probe, future phase)
|
|
* - GET /me/subscriptions (remote subscription status, future phase — blocked on Core endpoint)
|
|
*
|
|
* All requests are authenticated with the account-level API key, sent via
|
|
* the `Authorization: Bearer <key>` 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_option( 'robotstxt_manager_store_url', '' );
|
|
$api_key_raw = get_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<array<string,mixed>> 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_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_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_transient( 'robotstxt_manager_catalog' );
|
|
}
|
|
|
|
/**
|
|
* Returns the configured catalog-cache TTL in seconds.
|
|
*
|
|
* @return int
|
|
*/
|
|
private function get_catalog_ttl(): int {
|
|
$raw = get_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<string, mixed> 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<string, array<string, mixed>> Rows keyed by plugin slug.
|
|
*/
|
|
public function get_subscriptions(): array {
|
|
if ( ! $this->is_configured() ) {
|
|
return array();
|
|
}
|
|
|
|
$cache_key = 'robotstxt_manager_subscriptions';
|
|
$cached = get_transient( $cache_key );
|
|
|
|
if ( is_array( $cached ) ) {
|
|
$typed = array();
|
|
foreach ( $cached as $slug => $row ) {
|
|
if ( is_string( $slug ) && is_array( $row ) ) {
|
|
$typed[ $slug ] = $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'] ?? '';
|
|
|
|
$rows[ $slug ] = array(
|
|
'status' => is_string( $raw_status ) ? $raw_status : '',
|
|
'expires_at' => is_string( $raw_expires_at ) ? $raw_expires_at : '',
|
|
);
|
|
}
|
|
|
|
set_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`).
|
|
*
|
|
* @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 '';
|
|
}
|
|
|
|
$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 ) ),
|
|
'timeout' => self::TIMEOUT,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
|
return '';
|
|
}
|
|
|
|
$data = json_decode( wp_remote_retrieve_body( $response ), true );
|
|
|
|
$token = is_array( $data ) ? ( $data['token'] ?? '' ) : '';
|
|
|
|
return is_string( $token ) ? $token : '';
|
|
}
|
|
|
|
/**
|
|
* Clears the cached subscriptions response (key change, manual refresh).
|
|
*
|
|
* @return void
|
|
*/
|
|
public function clear_subscriptions_cache(): void {
|
|
delete_transient( 'robotstxt_manager_subscriptions' );
|
|
}
|
|
}
|