PluginProbe
Blocks – Reusable Content, Shortcodes & Site Variables / trunk
Blocks – Reusable Content, Shortcodes & Site Variables vtrunk
26.09.03.15 26.08.31.21 26.08.22.20 26.08.22.22 26.08.22.17 26.08.22.13 26.08.21.19 26.08.07.23 26.07.19.14 26.07.13.21 26.07.13.17 26.07.12.13 026.07.07.21 026.07.05.18 026.06.26.20 026.06.26.21 026.06.08.20 026.05.13.14 026.04.29.10 trunk 026.02.22.22 026.03.16.23 026.04.23.13
blocks / includes / signls / src / Transport.php

Transport.php in Blocks – Reusable Content, Shortcodes & Site Variables trunk, at includes/signls/src/Transport.php

449 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Acknowledged Signls delivery transport.
4 *
5 * @package signls-sdk
6 * @license GPL-3.0-or-later
7 */
8
9 namespace Signls\Sdk\V1;
10
11 defined( 'ABSPATH' ) || exit;
12
13 final class Transport {
14
15 private const DEFAULT_ENDPOINT = 'https://signls.dev';
16
17 private const SDK_VERSION = '1.1.10';
18
19 private const CLOCK_SKEW_SECONDS = 300;
20
21 private const QUARANTINE_CLASSES = array(
22 'invalid_json',
23 'invalid_schema',
24 'invalid_bounds',
25 'contract_violation',
26 'payload_too_large',
27 'unknown_product',
28 );
29
30 private $state;
31
32 private $identity;
33
34 private $site_identity;
35
36 public function __construct( StateStore $state, Identity $identity, SiteIdentity $site_identity ) {
37 $this->state = $state;
38 $this->identity = $identity;
39 $this->site_identity = $site_identity;
40 }
41
42 public static function version(): string {
43 return self::SDK_VERSION;
44 }
45
46 public function prepare( ProductAdapterInterface $adapter ) {
47 $revision = $this->payload_revision( $adapter );
48 $pending = $this->reconcile_pending_identity( $adapter, $revision );
49 if ( '' === $pending || '' === (string) $this->state->get( 'pending_quarantine_class', '' ) ) {
50 return null;
51 }
52
53 $probe_at = (int) $this->state->get( 'pending_quarantine_probe_at', 0 );
54 if ( $probe_at <= time() ) {
55 return null;
56 }
57
58 return array(
59 'ok' => false,
60 'class' => Sanitizer::slug( $this->state->get( 'pending_quarantine_class', '' ), 48, 'contract_violation' ),
61 'status' => (int) $this->state->get( 'pending_quarantine_status', 0 ),
62 'permanent' => true,
63 'quarantined' => true,
64 );
65 }
66
67 public function has_compatible_pending( ProductAdapterInterface $adapter ): bool {
68 return '' !== $this->reconcile_pending_identity( $adapter, $this->payload_revision( $adapter ) );
69 }
70
71 public function deliver( ProductAdapterInterface $adapter, array $payload ): array {
72 $started = time();
73 $this->state->set( 'last_attempt_started_at', $started );
74
75 if ( '' === (string) $this->state->get( 'credential_id', '' ) ) {
76 $enrollment = $this->enroll( $adapter, false );
77 if ( ! $enrollment['ok'] ) {
78 return $this->finish( $enrollment );
79 }
80 }
81
82 $result = $this->snapshot( $adapter, $payload, false, false );
83 if ( 'stale_timestamp' === $result['class'] && empty( $result['quarantined'] ) && isset( $result['server_time'] ) ) {
84 $server_time = (int) $result['server_time'];
85 $clear_future = $this->pending_observed_at_is_future( $server_time );
86 if ( $this->state->apply_clock_correction( $server_time - time(), $clear_future ) ) {
87 $result = $this->snapshot( $adapter, $payload, true, false );
88 }
89 }
90 return $this->finish( $result );
91 }
92
93 public static function retry_delay( int $attempt ): int {
94 $delays = array( 900, 3600, 14400, 43200, 86400 );
95 return $delays[ min( max( 0, $attempt ), count( $delays ) - 1 ) ];
96 }
97
98 private function enroll( ProductAdapterInterface $adapter, bool $clock_retry ): array {
99 $body = array(
100 'schema_version' => 1,
101 'sdk_version' => self::SDK_VERSION,
102 'product' => $adapter->product_slug(),
103 'install_id' => $adapter->install_id(),
104 'device_id' => $this->identity->device_id(),
105 'timestamp' => time() + (int) $this->state->get( 'clock_offset', 0 ),
106 );
107 $encoded = wp_json_encode( $body );
108 if ( ! is_string( $encoded ) ) {
109 return array(
110 'ok' => false,
111 'class' => 'invalid_payload',
112 'status' => 0,
113 'permanent' => true,
114 );
115 }
116 $response = $this->post( '/signals/v2/enroll', $encoded, array() );
117 if ( 'stale_timestamp' === $response['class'] && ! $clock_retry && isset( $response['data']['server_time'] ) ) {
118 $this->state->set( 'clock_offset', (int) $response['data']['server_time'] - time() );
119 return $this->enroll( $adapter, true );
120 }
121 if ( ! $response['ok'] ) {
122 return $response;
123 }
124
125 $data = $response['data'];
126 if ( ! isset( $data['credential_id'], $data['credential_secret'], $data['key_id'], $data['server_time'] ) || ! is_string( $data['credential_id'] ) || ! is_string( $data['credential_secret'] ) || ! is_string( $data['key_id'] ) ) {
127 return array(
128 'ok' => false,
129 'class' => 'invalid_response',
130 'status' => $response['status'],
131 'permanent' => true,
132 );
133 }
134
135 $this->state->set_many(
136 array(
137 'credential_id' => substr( $data['credential_id'], 0, 64 ),
138 'credential_secret' => substr( $data['credential_secret'], 0, 128 ),
139 'credential_key_id' => substr( $data['key_id'], 0, 48 ),
140 'clock_offset' => (int) $data['server_time'] - time(),
141 )
142 );
143 return array(
144 'ok' => true,
145 'class' => 'enrolled',
146 'status' => $response['status'],
147 'permanent' => false,
148 );
149 }
150
151 private function snapshot( ProductAdapterInterface $adapter, array $payload, bool $clock_retry, bool $sequence_retry ): array {
152 $contract = $adapter->contract();
153 $schema_version = isset( $contract['snapshot_schema_version'] ) ? (int) $contract['snapshot_schema_version'] : 1;
154 $schema_version = 2 === $schema_version ? 2 : 1;
155 $profile = 2 === $schema_version && isset( $contract['observation_profile'] ) ? Sanitizer::slug( $contract['observation_profile'], 48 ) : '';
156 $revision = $this->payload_revision( $adapter );
157 $pending = $this->reconcile_pending_identity( $adapter, $revision );
158 $timestamp = time() + (int) $this->state->get( 'clock_offset', 0 );
159 if ( '' === $pending ) {
160 $sequence = max( 1, (int) $this->state->get( 'last_acknowledged_sequence', 0 ) + 1 );
161 $body = array(
162 'schema_version' => $schema_version,
163 'sdk_version' => self::SDK_VERSION,
164 'product' => $adapter->product_slug(),
165 'device_id' => $this->identity->device_id(),
166 'install_id' => $adapter->install_id(),
167 );
168 if ( 2 === $schema_version ) {
169 $body['site_id'] = $this->site_identity->site_id();
170 $body['observation_profile'] = $profile;
171 }
172 $body['sequence'] = $sequence;
173 $body['observed_at'] = $timestamp;
174 $body['payload'] = $payload;
175 $pending = (string) wp_json_encode( $body );
176 $this->state->set_many(
177 array(
178 'pending_sequence' => $sequence,
179 'pending_body' => $pending,
180 'pending_body_hash' => hash( 'sha256', $pending ),
181 'pending_sdk_version' => self::SDK_VERSION,
182 'pending_product_version' => $adapter->product_version(),
183 'pending_payload_revision' => $revision,
184 )
185 );
186 }
187
188 $was_quarantined = '' !== (string) $this->state->get( 'pending_quarantine_class', '' );
189 if ( $was_quarantined ) {
190 $this->state->set( 'pending_quarantine_probe_at', time() + DAY_IN_SECONDS + random_int( 0, 3600 ) );
191 }
192
193 $secret = self::base64url_decode( (string) $this->state->get( 'credential_secret', '' ) );
194 if ( false === $secret || 32 !== strlen( $secret ) ) {
195 return array(
196 'ok' => false,
197 'class' => 'invalid_credential',
198 'status' => 0,
199 'permanent' => false,
200 );
201 }
202 $signature = self::base64url( hash_hmac( 'sha256', $timestamp . "\n" . hash( 'sha256', $pending ), $secret, true ) );
203 $response = $this->post(
204 '/signals/v2/snapshot',
205 $pending,
206 array(
207 'X-Signls-Credential' => (string) $this->state->get( 'credential_id', '' ),
208 'X-Signls-Timestamp' => (string) $timestamp,
209 'X-Signls-Signature' => 'v1=' . $signature,
210 )
211 );
212
213 if ( $response['ok'] ) {
214 $data = $response['data'];
215 $sequence = (int) $this->state->get( 'pending_sequence', 0 );
216 if ( empty( $data['accepted'] ) || (int) ( isset( $data['sequence'] ) ? $data['sequence'] : 0 ) !== $sequence ) {
217 $result = array(
218 'ok' => false,
219 'class' => 'invalid_response',
220 'status' => $response['status'],
221 'permanent' => true,
222 );
223 if ( $was_quarantined ) {
224 $result['quarantined'] = true;
225 }
226 return $result;
227 }
228 $this->state->set_many(
229 array(
230 'last_acknowledged_sequence' => $sequence,
231 'last_acknowledged_at' => (int) ( isset( $data['received_at'] ) ? $data['received_at'] : time() ),
232 'last_success_at' => time(),
233 'retry_attempt' => 0,
234 )
235 );
236 $this->state->clear_pending();
237 return array(
238 'ok' => true,
239 'class' => ! empty( $data['duplicate'] ) ? 'duplicate' : 'accepted',
240 'status' => $response['status'],
241 'permanent' => false,
242 );
243 }
244
245 if ( 'stale_timestamp' === $response['class'] && ! $clock_retry && isset( $response['data']['server_time'] ) && is_int( $response['data']['server_time'] ) && $response['data']['server_time'] > 0 ) {
246 $response['server_time'] = (int) $response['data']['server_time'];
247 }
248 if ( 'sequence_conflict' === $response['class'] ) {
249 $response['permanent'] = false;
250 $expected = isset( $response['data']['expected_sequence'] ) && is_int( $response['data']['expected_sequence'] ) ? $response['data']['expected_sequence'] : 0;
251 if ( ! $sequence_retry && $expected > 0 ) {
252 $this->state->reconcile_sequence( $expected );
253 return $this->snapshot( $adapter, $payload, $clock_retry, true );
254 }
255 }
256 if ( ! empty( $response['permanent'] ) && in_array( $response['class'], self::QUARANTINE_CLASSES, true ) ) {
257 return $this->quarantine( $response );
258 }
259 if ( $was_quarantined ) {
260 $response['quarantined'] = true;
261 }
262 return $response;
263 }
264
265 private function post( string $path, string $body, array $headers ): array {
266 $endpoint = $this->endpoint();
267 if ( '' === $endpoint ) {
268 return array(
269 'ok' => false,
270 'class' => 'invalid_endpoint',
271 'status' => 0,
272 'permanent' => true,
273 );
274 }
275
276 $headers['Content-Type'] = 'application/json';
277 $response = wp_remote_post(
278 $endpoint . $path,
279 array(
280 'body' => $body,
281 'headers' => $headers,
282 'timeout' => 10,
283 'redirection' => 0,
284 'blocking' => true,
285 )
286 );
287
288 if ( is_wp_error( $response ) ) {
289 $error_code = $response->get_error_code();
290 $error_code = is_scalar( $error_code ) ? substr( (string) $error_code, 0, 191 ) : '';
291 return array(
292 'ok' => false,
293 'class' => self::transport_class( $error_code ),
294 'status' => 0,
295 'permanent' => false,
296 );
297 }
298
299 $status = (int) wp_remote_retrieve_response_code( $response );
300 $raw = (string) wp_remote_retrieve_body( $response );
301 $data = json_decode( $raw, true );
302 $data = is_array( $data ) ? $data : array();
303 if ( in_array( $status, array( 200, 201, 202 ), true ) ) {
304 return array(
305 'ok' => true,
306 'class' => 'success',
307 'status' => $status,
308 'data' => $data,
309 'permanent' => false,
310 );
311 }
312
313 $code = Sanitizer::slug( isset( $data['code'] ) ? $data['code'] : '', 48, 'http_' . $status );
314 $transient = in_array( $status, array( 408, 425, 429 ), true ) || $status >= 500;
315 return array(
316 'ok' => false,
317 'class' => $code,
318 'status' => $status,
319 'data' => $data,
320 'permanent' => ! $transient,
321 );
322 }
323
324 private function endpoint(): string {
325 $endpoint = self::DEFAULT_ENDPOINT;
326 if ( function_exists( 'apply_filters' ) ) {
327 $endpoint = (string) apply_filters( 'signls_sdk_v1_endpoint', $endpoint, $this->state->product() );
328 }
329 $endpoint = untrailingslashit( $endpoint );
330 $parts = wp_parse_url( $endpoint );
331 $host = isset( $parts['host'] ) ? strtolower( $parts['host'] ) : '';
332 $scheme = isset( $parts['scheme'] ) ? strtolower( $parts['scheme'] ) : '';
333 $local = 'localhost' === $host || '.test' === substr( $host, -5 );
334 return ( 'https' === $scheme || ( 'http' === $scheme && $local ) ) ? $endpoint : '';
335 }
336
337 private function finish( array $result ): array {
338 $values = array(
339 'last_attempt_finished_at' => time(),
340 'last_http_status' => (int) ( isset( $result['status'] ) ? $result['status'] : 0 ),
341 );
342 if ( $result['ok'] ) {
343 $this->state->delete_keys( array( 'failure_class', 'next_retry_at' ) );
344 $this->state->set_many( $values );
345 return $result;
346 }
347 $values['failure_class'] = Sanitizer::slug( isset( $result['class'] ) ? $result['class'] : '', 48, 'unknown' );
348 if ( ! empty( $result['quarantined'] ) ) {
349 $values['retry_attempt'] = 0;
350 $this->state->delete_keys( array( 'next_retry_at' ) );
351 $this->state->set_many( $values );
352 return $result;
353 }
354 $attempt = (int) $this->state->get( 'retry_attempt', 0 );
355 $values['retry_attempt'] = $attempt + 1;
356 if ( in_array( $values['failure_class'], array( 'invalid_credential', 'device_already_enrolled' ), true ) ) {
357 $last_rotation = (int) $this->state->get( 'last_device_rotation_at', 0 );
358 if ( time() - $last_rotation >= DAY_IN_SECONDS ) {
359 $this->identity->rotate_device();
360 $values['last_device_rotation_at'] = time();
361 }
362 $result['permanent'] = false;
363 }
364 if ( empty( $result['permanent'] ) ) {
365 $values['next_retry_at'] = time() + self::retry_delay( $attempt ) + random_int( 0, 300 );
366 }
367 $this->state->set_many( $values );
368 return $result;
369 }
370
371 private function payload_revision( ProductAdapterInterface $adapter ): int {
372 $contract = $adapter->contract();
373 $revision = isset( $contract['snapshot_payload_revision'] ) ? (int) $contract['snapshot_payload_revision'] : 1;
374 return $revision > 0 ? $revision : 1;
375 }
376
377 private function pending_observed_at_is_future( int $server_time ): bool {
378 $pending = json_decode( (string) $this->state->get( 'pending_body', '' ), true );
379 return is_array( $pending )
380 && isset( $pending['observed_at'] )
381 && is_int( $pending['observed_at'] )
382 && $pending['observed_at'] > $server_time + self::CLOCK_SKEW_SECONDS;
383 }
384
385 private function reconcile_pending_identity( ProductAdapterInterface $adapter, int $revision ): string {
386 $pending = (string) $this->state->get( 'pending_body', '' );
387 if ( '' === $pending ) {
388 return '';
389 }
390 if (
391 self::SDK_VERSION !== (string) $this->state->get( 'pending_sdk_version', '' )
392 || $adapter->product_version() !== (string) $this->state->get( 'pending_product_version', '' )
393 || $revision !== (int) $this->state->get( 'pending_payload_revision', 1 )
394 ) {
395 $this->state->clear_pending();
396 return '';
397 }
398 return $pending;
399 }
400
401 private function quarantine( array $response ): array {
402 $now = time();
403 $probe_at = (int) $this->state->get( 'pending_quarantine_probe_at', 0 );
404 if ( $probe_at <= $now ) {
405 $probe_at = $now + DAY_IN_SECONDS + random_int( 0, 3600 );
406 }
407 $quarantined_at = (int) $this->state->get( 'pending_quarantined_at', 0 );
408 $this->state->set_many(
409 array(
410 'pending_quarantine_class' => Sanitizer::slug( $response['class'], 48, 'contract_violation' ),
411 'pending_quarantine_status' => (int) $response['status'],
412 'pending_quarantined_at' => $quarantined_at > 0 ? $quarantined_at : $now,
413 'pending_quarantine_probe_at' => $probe_at,
414 'retry_attempt' => 0,
415 )
416 );
417 $this->state->delete_keys( array( 'next_retry_at' ) );
418 $response['permanent'] = true;
419 $response['quarantined'] = true;
420 return $response;
421 }
422
423 private static function transport_class( string $code ): string {
424 $code = strtolower( $code );
425 if ( false !== strpos( $code, 'resolve' ) || false !== strpos( $code, 'dns' ) ) {
426 return 'transport_dns';
427 }
428 if ( false !== strpos( $code, 'ssl' ) || false !== strpos( $code, 'tls' ) ) {
429 return 'transport_tls';
430 }
431 if ( false !== strpos( $code, 'timeout' ) ) {
432 return 'transport_timeout';
433 }
434 return 'transport_connect';
435 }
436
437 private static function base64url( string $value ): string {
438 return rtrim( strtr( base64_encode( $value ), '+/', '-_' ), '=' );
439 }
440
441 private static function base64url_decode( string $value ) {
442 if ( '' === $value || 1 !== preg_match( '/^[A-Za-z0-9_-]+$/', $value ) ) {
443 return false;
444 }
445 $padding = ( 4 - strlen( $value ) % 4 ) % 4;
446 return base64_decode( strtr( $value, '-_', '+/' ) . str_repeat( '=', $padding ), true );
447 }
448 }
449