| 1 |
<?php |
| 2 |
/** |
| 3 |
* WCPOS sync write header mirror. |
| 4 |
* |
| 5 |
* @package WCPOS\WooCommercePOS\Sync |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace WCPOS\WooCommercePOS\Sync; |
| 9 |
|
| 10 |
use WP_Error; |
| 11 |
use WP_REST_Request; |
| 12 |
|
| 13 |
// phpcs:disable Squiz.Commenting, Generic.Commenting -- Ported lab documentation is preserved verbatim. |
| 14 |
|
| 15 |
/** |
| 16 |
* Standard-header MIRROR cross-check (ADR 0011) for the write path — the generic |
| 17 |
* per-collection push (Woo_RxDB_Sync_Write_Controller, /push/{collection}). |
| 18 |
* |
| 19 |
* The JSON body stays CANONICAL. The client MAY also send the sync-control signals as standard HTTP |
| 20 |
* headers — Idempotency-Key (= mutationId) and If-Match (= baseRevision) — for a standards-shaped wire |
| 21 |
* surface (proxies/observability). These headers are only a cross-check: a mangled-but-parseable header |
| 22 |
* (a proxy requoting, a Cloudflare-weakened ETag) is worse than a missing one, so when BOTH a header and |
| 23 |
* its body field are present and DISAGREE we reject (422) rather than trust either side. |
| 24 |
*/ |
| 25 |
final class Header_Mirror { |
| 26 |
/** The header names this contract adds, in their canonical (sent) casing. */ |
| 27 |
public const HEADERS = array( 'Idempotency-Key', 'If-Match' ); |
| 28 |
|
| 29 |
/** |
| 30 |
* @return WP_Error|null A 422 WP_Error on header/body divergence, or null when they agree or are absent. |
| 31 |
*/ |
| 32 |
public static function assert( WP_REST_Request $request, string $body_mutation_id, $body_base_revision ) { |
| 33 |
$header_mutation_id = self::header_value( $request, 'idempotency-key' ); |
| 34 |
if ( null !== $header_mutation_id && $header_mutation_id !== $body_mutation_id ) { |
| 35 |
return new WP_Error( 'woo_rxdb_sync_header_body_mismatch', 'Idempotency-Key header disagrees with body mutationId.', array( 'status' => 422 ) ); |
| 36 |
} |
| 37 |
$header_base_revision = self::unquote_entity_tag( self::header_value( $request, 'if-match' ) ); |
| 38 |
// A non-string baseRevision (a malformed body) is treated as absent, not cast to "Array" with a notice. |
| 39 |
$body_revision = is_string( $body_base_revision ) ? $body_base_revision : ''; |
| 40 |
if ( null !== $header_base_revision && $header_base_revision !== $body_revision ) { |
| 41 |
return new WP_Error( 'woo_rxdb_sync_header_body_mismatch', 'If-Match header disagrees with body baseRevision.', array( 'status' => 422 ) ); |
| 42 |
} |
| 43 |
return null; |
| 44 |
} |
| 45 |
|
| 46 |
private static function header_value( WP_REST_Request $request, string $name ): ?string { |
| 47 |
$value = $request->get_header( $name ); |
| 48 |
if ( null === $value ) { |
| 49 |
return null; |
| 50 |
} |
| 51 |
$value = trim( (string) $value ); |
| 52 |
return '' === $value ? null : $value; |
| 53 |
} |
| 54 |
|
| 55 |
/** Strip an optional weak indicator (W/) and surrounding double quotes from an entity-tag, so the bare |
| 56 |
* revision compares equal even if an intermediary weakened a strong tag (RFC 9110 §8.8.3). */ |
| 57 |
private static function unquote_entity_tag( ?string $value ): ?string { |
| 58 |
if ( null === $value ) { |
| 59 |
return null; |
| 60 |
} |
| 61 |
$value = preg_replace( '/^W\//', '', trim( $value ) ); |
| 62 |
if ( strlen( $value ) >= 2 && '"' === $value[0] && '"' === substr( $value, -1 ) ) { |
| 63 |
$value = substr( $value, 1, -1 ); |
| 64 |
} |
| 65 |
return $value; |
| 66 |
} |
| 67 |
} |
| 68 |
|