PluginProbe
Code Snippets / trunk
Code Snippets vtrunk
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / php / Model / Feedback_Connection.php

Feedback_Connection.php in Code Snippets trunk, at php/Model/Feedback_Connection.php

207 lines 6.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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