| 1 |
<?php |
| 2 |
|
| 3 |
namespace Code_Snippets\Model; |
| 4 |
|
| 5 |
use Code_Snippets\Utils\System_Info; |
| 6 |
|
| 7 |
/** |
| 8 |
* Connection to the Code Snippets Cloud reporting API. |
| 9 |
* |
| 10 |
* The reporting endpoint is resolved on its own rather than from the cloud API URL the rest |
| 11 |
* of the plugin uses. Those are separate services: a site pointed at a local cloud build for |
| 12 |
* development would otherwise send its reports there too, where nobody would read them, and |
| 13 |
* the reports would fail outright whenever that build was not running. |
| 14 |
* |
| 15 |
* Trust model: the programme key below is public. It ships in the plugin source and identifies |
| 16 |
* the reporting programme, not the site, in the same way as the public token on the parent |
| 17 |
* class. Per-site authenticity comes from the registration handshake instead: a site enrols |
| 18 |
* once, receives a public identifier and a secret, and signs every later request with an |
| 19 |
* HMAC over `timestamp.METHOD.request_uri.sha256(body)`. Binding the signature to the method, |
| 20 |
* path, query string and body gives the cloud replay protection within its accepted timestamp |
| 21 |
* window. The secret is stored without autoloading, never reaches the browser and is never |
| 22 |
* included in a REST response. Rate limiting beyond the reporter's own throttle is the |
| 23 |
* cloud's responsibility. |
| 24 |
* |
| 25 |
* @package Code_Snippets |
| 26 |
*/ |
| 27 |
class Feedback_Connection extends Basic_Cloud_Connection { |
| 28 |
|
| 29 |
/** |
| 30 |
* Public key identifying the reporting programme. |
| 31 |
*/ |
| 32 |
private const PROGRAMME_KEY = 'csb_nhv937hQa0mbBNyB0n9FTQvXZR6i3d9UA2OAZU2E04lu9loS'; |
| 33 |
|
| 34 |
/** |
| 35 |
* Host serving the reporting API. |
| 36 |
*/ |
| 37 |
private const REPORTS_HOST = 'https://codesnippets.cloud'; |
| 38 |
|
| 39 |
/** |
| 40 |
* Path of the reporting endpoint, relative to the reporting host. |
| 41 |
*/ |
| 42 |
private const REPORTS_PATH = 'api/v1/beta-reports'; |
| 43 |
|
| 44 |
/** |
| 45 |
* Name of the option holding this site's credential. |
| 46 |
*/ |
| 47 |
public const CREDENTIALS_OPTION = 'code_snippets_feedback_credentials'; |
| 48 |
|
| 49 |
/** |
| 50 |
* Shape of a public identifier issued by the cloud. |
| 51 |
*/ |
| 52 |
private const PUBLIC_ID_PATTERN = '/^[A-Za-z0-9_-]{16,128}$/'; |
| 53 |
|
| 54 |
/** |
| 55 |
* Shape of a secret issued by the cloud. |
| 56 |
*/ |
| 57 |
private const SECRET_PATTERN = '/^[A-Za-z0-9]{32,128}$/'; |
| 58 |
|
| 59 |
/** |
| 60 |
* Retrieve the key identifying the reporting programme. |
| 61 |
* |
| 62 |
* @return string |
| 63 |
*/ |
| 64 |
public function get_key(): string { |
| 65 |
return apply_filters( 'code_snippets_feedback_key', self::PROGRAMME_KEY ); |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Retrieve the host serving the reporting API. |
| 70 |
* |
| 71 |
* `CS_BETA_FEEDBACK_HOST` names a reporting service on its own, so it wins over the |
| 72 |
* cloud URL a site may be pointing elsewhere for unrelated development. |
| 73 |
* |
| 74 |
* @return string |
| 75 |
* |
| 76 |
* @noinspection PhpUndefinedConstantInspection |
| 77 |
*/ |
| 78 |
public function get_host(): string { |
| 79 |
$host = self::REPORTS_HOST; |
| 80 |
|
| 81 |
if ( defined( 'CS_BETA_FEEDBACK_HOST' ) && CS_BETA_FEEDBACK_HOST ) { |
| 82 |
$host = CS_BETA_FEEDBACK_HOST; |
| 83 |
} |
| 84 |
|
| 85 |
return untrailingslashit( apply_filters( 'code_snippets_feedback_host', $host ) ); |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* Retrieve the URL of a reporting endpoint. |
| 90 |
* |
| 91 |
* @param string $path Optional path below the reporting endpoint. |
| 92 |
* |
| 93 |
* @return string |
| 94 |
*/ |
| 95 |
public function get_endpoint_url( string $path = '' ): string { |
| 96 |
$url = sprintf( '%s/%s', $this->get_host(), self::REPORTS_PATH ); |
| 97 |
|
| 98 |
if ( $path ) { |
| 99 |
$url .= '/' . ltrim( $path, '/' ); |
| 100 |
} |
| 101 |
|
| 102 |
return apply_filters( 'code_snippets_feedback_endpoint_url', $url, $path ); |
| 103 |
} |
| 104 |
|
| 105 |
/** |
| 106 |
* Retrieve the credential issued to this site. |
| 107 |
* |
| 108 |
* @return array<string, mixed> Empty when this site has not enrolled. |
| 109 |
*/ |
| 110 |
public function get_credentials(): array { |
| 111 |
$stored = get_option( self::CREDENTIALS_OPTION, [] ); |
| 112 |
|
| 113 |
return is_array( $stored ) ? $stored : []; |
| 114 |
} |
| 115 |
|
| 116 |
/** |
| 117 |
* Store the credential issued to this site. |
| 118 |
* |
| 119 |
* @param array<string, mixed> $credentials Credential to store. |
| 120 |
* |
| 121 |
* @return void |
| 122 |
*/ |
| 123 |
public function save_credentials( array $credentials ): void { |
| 124 |
update_option( self::CREDENTIALS_OPTION, $credentials, false ); |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Discard the credential issued to this site. |
| 129 |
* |
| 130 |
* @return void |
| 131 |
*/ |
| 132 |
public function delete_credentials(): void { |
| 133 |
delete_option( self::CREDENTIALS_OPTION ); |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Determine whether a credential has the shape the cloud issues. |
| 138 |
* |
| 139 |
* @param array<string, mixed> $credentials Credential to check. |
| 140 |
* |
| 141 |
* @return bool |
| 142 |
*/ |
| 143 |
public function is_valid_credentials( array $credentials ): bool { |
| 144 |
return ! empty( $credentials['public_id'] ) && ! empty( $credentials['secret'] ) |
| 145 |
&& preg_match( self::PUBLIC_ID_PATTERN, (string) $credentials['public_id'] ) |
| 146 |
&& preg_match( self::SECRET_PATTERN, (string) $credentials['secret'] ); |
| 147 |
} |
| 148 |
|
| 149 |
/** |
| 150 |
* Create the headers common to every reporting request. |
| 151 |
* |
| 152 |
* @return array<string, string> |
| 153 |
*/ |
| 154 |
public function get_request_headers(): array { |
| 155 |
$headers = [ |
| 156 |
'Content-Type' => 'application/json; charset=utf-8', |
| 157 |
'Accept' => 'application/json', |
| 158 |
'X-CS-Site' => site_url(), |
| 159 |
'X-CS-Edition' => System_Info::get_edition(), |
| 160 |
]; |
| 161 |
|
| 162 |
$key = $this->get_key(); |
| 163 |
|
| 164 |
if ( $key ) { |
| 165 |
$headers['Authorization'] = 'Bearer ' . $key; |
| 166 |
} |
| 167 |
|
| 168 |
return $headers; |
| 169 |
} |
| 170 |
|
| 171 |
/** |
| 172 |
* Create the headers proving a request came from this site. |
| 173 |
* |
| 174 |
* @param array<string, mixed> $credentials Credential issued to this site. |
| 175 |
* @param string $method HTTP method. |
| 176 |
* @param string $uri Request path, including any query string. |
| 177 |
* @param string $body Raw request body, empty for a GET. |
| 178 |
* |
| 179 |
* @return array<string, string> |
| 180 |
*/ |
| 181 |
public function get_signature_headers( array $credentials, string $method, string $uri, string $body ): array { |
| 182 |
$offset = isset( $credentials['offset'] ) ? (int) $credentials['offset'] : 0; |
| 183 |
$timestamp = (string) ( time() + $offset ); |
| 184 |
$payload = $timestamp . '.' . strtoupper( $method ) . '.' . $uri . '.' . hash( 'sha256', $body ); |
| 185 |
|
| 186 |
return [ |
| 187 |
'X-CS-Site-Id' => (string) $credentials['public_id'], |
| 188 |
'X-CS-Timestamp' => $timestamp, |
| 189 |
'X-CS-Signature' => hash_hmac( 'sha256', $payload, (string) $credentials['secret'] ), |
| 190 |
]; |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Reduce a URL to the part a signature covers. |
| 195 |
* |
| 196 |
* @param string $url Absolute URL. |
| 197 |
* |
| 198 |
* @return string Path, followed by the query string when there is one. |
| 199 |
*/ |
| 200 |
public static function get_request_uri( string $url ): string { |
| 201 |
$path = (string) wp_parse_url( $url, PHP_URL_PATH ); |
| 202 |
$query = wp_parse_url( $url, PHP_URL_QUERY ); |
| 203 |
|
| 204 |
return $query ? $path . '?' . $query : $path; |
| 205 |
} |
| 206 |
} |
| 207 |
|