PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Sync / Response_Envelope.php

Response_Envelope.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Sync/Response_Envelope.php

185 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Opt-in WCPOS REST response envelope.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WCPOS\WooCommercePOS\API as Root_API;
11 use WP_HTTP_Response;
12 use WP_REST_Request;
13
14 /** Mirrors response metadata into an opt-in body envelope. */
15 final class Response_Envelope {
16 /** Register the response filter once. */
17 public static function register_hooks(): void {
18 if ( false === has_filter( 'rest_post_dispatch', array( self::class, 'filter_response' ) ) ) {
19 add_filter( 'rest_post_dispatch', array( self::class, 'filter_response' ), PHP_INT_MAX - 1, 3 );
20 }
21 }
22
23 /**
24 * Wrap an opted-in successful WCPOS response.
25 *
26 * @param mixed $response REST response.
27 * @param mixed $server REST server.
28 * @param WP_REST_Request $request REST request.
29 *
30 * @return mixed
31 */
32 public static function filter_response( $response, $server, WP_REST_Request $request ) {
33 $route = strtolower( '/' . ltrim( $request->get_route(), '/' ) );
34 if (
35 ! $response instanceof WP_HTTP_Response
36 || '1' !== (string) $request->get_param( '_wcpos_envelope' )
37 || ! self::is_wcpos_request( $request, $route )
38 || 0 === strpos( $route, '/' . Api::ROUTE_NAMESPACE . '/push/' )
39 // Raw byte responses (receipt PDFs, printer payloads) serve
40 // get_raw_body() through their own rest_pre_serve_request callback;
41 // wrapping their unused JSON data would promise an envelope the
42 // client never receives.
43 || $response instanceof \WCPOS\WooCommercePOS\API\V1\Raw_Response
44 // The reachability ping is deliberately dependency-free: its live
45 // fast path (Ping::maybe_serve) echoes and exits before any REST
46 // filter exists, and its payload already body-mirrors the pressure
47 // metadata. Exempting it here keeps the REST fallback identical to
48 // the fast path instead of pretending coverage the live route
49 // cannot have.
50 || '/' . Api::ROUTE_NAMESPACE . '/ping' === $route
51 || 304 === $response->get_status()
52 || $response->get_status() >= 400
53 ) {
54 return $response;
55 }
56
57 $headers = array_change_key_case( $response->get_headers(), CASE_LOWER );
58 $meta = array( 'v' => 1 );
59 foreach ( array(
60 'total' => 'x-wp-total',
61 'total_pages' => 'x-wp-totalpages',
62 ) as $field => $header ) {
63 if ( isset( $headers[ $header ] ) && is_numeric( $headers[ $header ] ) && (int) $headers[ $header ] >= 0 ) {
64 $meta[ $field ] = (int) $headers[ $header ];
65 }
66 }
67
68 $page = $request->get_param( 'page' );
69 if ( is_numeric( $page ) && (int) $page > 0 ) {
70 $meta['page'] = (int) $page;
71 } elseif ( isset( $meta['total_pages'] ) ) {
72 $meta['page'] = 1;
73 }
74
75 if ( isset( $headers['etag'] ) ) {
76 if ( '' !== (string) $headers['etag'] ) {
77 $meta['validator'] = (string) $headers['etag'];
78 }
79 } else {
80 if ( isset( $headers['x-wcpos-pressure'] ) && '' !== (string) $headers['x-wcpos-pressure'] ) {
81 $meta['pressure'] = (string) $headers['x-wcpos-pressure'];
82 }
83
84 if ( isset( $headers['x-server-load'] ) ) {
85 $server_load = json_decode( (string) $headers['x-server-load'], true );
86 if ( is_array( $server_load ) && 3 === count( $server_load ) ) {
87 $meta['server_load'] = array_values( $server_load );
88 }
89 }
90
91 if ( isset( $headers['x-wcpos-memory-peak'] ) && (int) $headers['x-wcpos-memory-peak'] >= 0 ) {
92 $meta['memory_peak_bytes'] = (int) $headers['x-wcpos-memory-peak'];
93 }
94 }
95
96 // Serialize response-level links INTO data before wrapping (single-item
97 // responses attach _links at serving time via response_to_data; without
98 // this, WP would append _links as a third top-level key and body.data
99 // would lose them), then clear them so they are not appended again.
100 $data = $response->get_data();
101 if ( $response instanceof \WP_REST_Response && \function_exists( 'rest_get_server' ) ) {
102 $links = $response->get_links();
103 if ( array() !== $links ) {
104 $data = rest_get_server()->response_to_data( $response, false );
105 foreach ( array_keys( $links ) as $rel ) {
106 $response->remove_link( (string) $rel );
107 }
108 }
109 }
110
111 $response->set_data(
112 array(
113 'data' => $data,
114 '_wcpos' => $meta,
115 )
116 );
117
118 return $response;
119 }
120
121 /**
122 * Whether this is a WCPOS namespace route or explicitly marked request.
123 *
124 * The marker is read from BOTH carriers, exactly as `wcpos_request()` reads
125 * it, and for the reason that helper has two: a proxy or WAF that strips
126 * request headers deletes `X-WCPOS` in transit, which is why the client
127 * publishes the same marker as the `wcpos` query var and sends it
128 * unconditionally. Checking only the header made the body mirror die on
129 * precisely the hostile condition it exists to survive — a stripping proxy
130 * removes `X-WP-Total` from the response AND `X-WCPOS` from the request, so
131 * a non-namespace route lost the header and its fallback together and no
132 * total could reach the client at all.
133 *
134 * `Init::init_rest_api()` already treats the query var as sufficient to load
135 * the whole POS API; a request that reached a route that way must be able to
136 * reach its envelope too, or the two disagree about what a POS request is.
137 * This does not widen the opt-in: `_wcpos_envelope=1` is still required, and
138 * route registration is untouched.
139 *
140 * @param WP_REST_Request $request REST request.
141 * @param string $route Normalized REST route.
142 */
143 private static function is_wcpos_request( WP_REST_Request $request, string $route ): bool {
144 foreach ( Root_API::ROUTE_NAMESPACES as $namespace ) {
145 if ( 0 === strpos( $route, '/' . strtolower( $namespace ) . '/' ) ) {
146 return true;
147 }
148 }
149
150 if ( '1' === trim( (string) $request->get_header( 'X-WCPOS' ) ) ) {
151 return true;
152 }
153
154 // The query-var marker is ambient — it belongs to the request WordPress is
155 // SERVING, not to every request dispatched while serving it. Core re-applies
156 // `rest_post_dispatch` to sub-requests in two places: `_embed` expansion
157 // (class-wp-rest-server.php) and the batch endpoint. Without this check a
158 // marked `?_embed=1` read would wrap each embedded sub-response too, and the
159 // client would find `{data,_wcpos}` where it expects an embedded record.
160 // The header carrier has no such problem — a sub-request is constructed fresh
161 // and carries no headers — which is why only this branch is gated.
162 return self::is_served_route( $route ) && \wcpos_request( 'query_var' );
163 }
164
165 /**
166 * Whether this route is the one WordPress is actually serving.
167 *
168 * `rest_route` holds the OUTER route for the whole request and, as
169 * `wcpos_request()` documents, keeps that value during internal re-dispatches —
170 * which is exactly what makes it usable to tell an outer request from a
171 * sub-request. Anything that is not the served route is a sub-request.
172 *
173 * @param string $route Normalized REST route.
174 */
175 private static function is_served_route( string $route ): bool {
176 global $wp;
177 $outer = isset( $wp->query_vars['rest_route'] ) ? (string) $wp->query_vars['rest_route'] : '';
178 if ( '' === $outer ) {
179 return false;
180 }
181
182 return strtolower( '/' . ltrim( $outer, '/' ) ) === $route;
183 }
184 }
185