PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.12 1.9.11 1.9.10 1.9.9 All 158 releases
woocommerce-pos / includes / Sync / Meta_Normalizer.php

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

370 lines 12.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS sync wire normalization.
4 *
5 * @package WCPOS\WooCommercePOS\Sync
6 */
7
8 namespace WCPOS\WooCommercePOS\Sync;
9
10 use WCPOS\WooCommercePOS\Logger;
11
12 /**
13 * Normalizes structured meta values before sync documents are hashed or emitted.
14 */
15 final class Meta_Normalizer {
16 /**
17 * Upper bound on array elements and object properties in ONE meta value, counted
18 * across every nesting level. The POS's own structured meta (`_woocommerce_pos_data`,
19 * attribute maps, line-item meta) is a few hundred nodes; anything past this is another
20 * plugin's bulk data and is not served: one store carried a 16.7-million-element meta
21 * value, and the JSON round trip below killed every request that touched the record
22 * (Sentry WOOCOMMERCE-POS-2KQ, ~22 fatals an hour).
23 */
24 public const OVERSIZED_META_NODE_LIMIT = 20000;
25
26 /**
27 * Upper bound on string bytes in ONE meta value, summed across nesting and keys (8 MiB):
28 * the same cut-off for values that are few entries but enormous strings.
29 *
30 * Deliberately well clear of legitimate use. `Test_Catalog_Proxy_Meta_Scaling` pins a
31 * 1 MiB meta value as something that must round-trip, so the cut-off sits eight times
32 * above the largest size this repo asserts is normal, and still three orders of
33 * magnitude below the gigabyte that took a store's requests down.
34 */
35 public const OVERSIZED_META_BYTE_LIMIT = 8388608;
36
37 /**
38 * Meta keys already reported as oversized in this request (one warning per key).
39 *
40 * @var array<string, true>
41 */
42 private static array $oversized_logged = array();
43
44 /**
45 * Clear the per-request set of already-reported keys.
46 *
47 * The dedupe is request-scoped in production, where the process ends with the
48 * response. Long-lived processes and the test suite share one process across many
49 * requests, so they reset at the boundary like the other request-scoped collectors.
50 */
51 public static function reset_request_state(): void {
52 self::$oversized_logged = array();
53 }
54
55 /**
56 * Register the shared pre-stamping normalization seams.
57 */
58 public static function register_hooks(): void {
59 add_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'normalize' ), 5 );
60 add_filter( 'woocommerce_pos_sync_serialized_product', array( __CLASS__, 'normalize' ), 5 );
61 add_filter( 'woocommerce_pos_sync_serialized_order', array( __CLASS__, 'normalize' ), 5 );
62 }
63
64 /**
65 * Unregister the shared pre-stamping normalization seams.
66 */
67 public static function unregister_hooks(): void {
68 remove_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'normalize' ), 5 );
69 remove_filter( 'woocommerce_pos_sync_serialized_product', array( __CLASS__, 'normalize' ), 5 );
70 remove_filter( 'woocommerce_pos_sync_serialized_order', array( __CLASS__, 'normalize' ), 5 );
71 }
72
73 /**
74 * Recursively normalize every meta_data array in a document or payload.
75 *
76 * @param mixed $payload Document or payload being prepared for the wire.
77 *
78 * @return mixed
79 */
80 public static function normalize( $payload ) {
81 if ( ! is_array( $payload ) ) {
82 return $payload;
83 }
84
85 foreach ( $payload as $key => $value ) {
86 if ( 'meta_data' === $key && is_array( $value ) ) {
87 $value = self::normalize_meta_data( $value );
88 }
89
90 $payload[ $key ] = is_array( $value ) ? self::normalize( $value ) : $value;
91 }
92
93 return $payload;
94 }
95
96 /**
97 * Shape-tolerant reader for structured meta that may be stored in either
98 * form: the historical JSON-encoded string, or (after a typed client push
99 * lands through wc/v3) a native PHP array. Server-side consumers of
100 * `_woocommerce_pos_data`-style meta must read through this — a bare
101 * `json_decode( $raw )` fatals on PHP 8 the moment the storage holds an
102 * array.
103 *
104 * @param mixed $raw Meta value as returned by get_meta().
105 *
106 * @return array|null Decoded associative array, or null when the value is
107 * neither a JSON object/array string nor an array.
108 */
109 public static function decode_to_array( $raw ): ?array {
110 if ( is_array( $raw ) ) {
111 return $raw;
112 }
113 if ( is_object( $raw ) ) {
114 $raw = wp_json_encode( $raw );
115 }
116 if ( ! is_string( $raw ) || '' === $raw ) {
117 return null;
118 }
119 $decoded = json_decode( $raw, true );
120 if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
121 return null;
122 }
123
124 return $decoded;
125 }
126
127 /**
128 * Normalize structured meta values and derived display fields.
129 *
130 * @param array $meta_data Serialized REST meta entries.
131 *
132 * @return array
133 */
134 private static function normalize_meta_data( array $meta_data ): array {
135 $was_list = array_keys( $meta_data ) === array_keys( array_values( $meta_data ) );
136 $dropped = false;
137 foreach ( $meta_data as $index => $entry ) {
138 // Top-level entity meta reaches the filters as live WC_Meta_Data objects
139 // (they only become arrays at JSON-encode time); convert a copy to the
140 // exact shape it would serialize to, and only swap it in when normalization
141 // actually happens — untouched entries keep their original form so
142 // revision hashes of scalar-only records are unchanged. The budget check
143 // runs on the live value BEFORE the round trip: encoding an oversized value
144 // is the allocation that took the whole request down.
145 $is_meta_object = $entry instanceof \WC_Meta_Data;
146 if ( $is_meta_object ) {
147 $data = $entry->get_data();
148 if ( self::exceeds_value_budget( $data['value'] ?? null ) ) {
149 self::drop_oversized( $meta_data, $index, $data );
150 $dropped = true;
151 continue;
152 }
153 $entry = json_decode( wp_json_encode( $data ), true );
154 }
155
156 if ( ! is_array( $entry ) ) {
157 continue;
158 }
159
160 if ( ! $is_meta_object && self::exceeds_value_budget( $entry['value'] ?? null ) ) {
161 self::drop_oversized( $meta_data, $index, $entry );
162 $dropped = true;
163 continue;
164 }
165
166 $entry_changed = false;
167 if ( isset( $entry['value'] ) && is_string( $entry['value'] ) ) {
168 $raw = $entry['value'];
169 $trimmed = trim( $raw );
170 $opening = substr( $trimmed, 0, 1 );
171 if ( '{' === $opening || '[' === $opening ) {
172 $decoded = json_decode( $raw );
173 if ( JSON_ERROR_NONE === json_last_error() && ( is_array( $decoded ) || $decoded instanceof \stdClass ) ) {
174 $entry['value'] = self::preserve_json_object_shape( $decoded );
175 $entry_changed = true;
176 }
177 }
178 }
179 foreach ( array( 'display_key', 'display_value' ) as $display_field ) {
180 if ( array_key_exists( $display_field, $entry ) && ! is_string( $entry[ $display_field ] ) ) {
181 unset( $entry[ $display_field ] );
182 $entry_changed = true;
183 }
184 }
185
186 if ( ! $is_meta_object || $entry_changed ) {
187 $meta_data[ $index ] = $entry;
188 }
189 }
190
191 if ( $dropped && $was_list ) {
192 // A list with a hole JSON-encodes as an object; the wire expects a list.
193 $meta_data = array_values( $meta_data );
194 }
195
196 return $meta_data;
197 }
198
199 /**
200 * Remove an oversized entry from the payload and say so once per key per request.
201 * The value itself is never logged.
202 *
203 * @param array $meta_data Serialized REST meta entries (by reference).
204 * @param int|string $index Index of the entry to drop.
205 * @param array $entry Array form of the entry (`id`, `key`, `value`).
206 */
207 private static function drop_oversized( array &$meta_data, $index, array $entry ): void {
208 unset( $meta_data[ $index ] );
209 self::note_oversized_meta(
210 isset( $entry['key'] ) ? (string) $entry['key'] : '',
211 (int) ( $entry['id'] ?? 0 )
212 );
213 }
214
215 /**
216 * Record that an oversized meta entry was withheld, once per key per request.
217 * The value is never logged. Shared with the v1 lane, which drops the same
218 * entries at its own serializer rather than through this class.
219 *
220 * @param string $key Meta key that was withheld.
221 * @param int $meta_id Meta row id, when known.
222 */
223 public static function note_oversized_meta( string $key, int $meta_id = 0 ): void {
224 if ( isset( self::$oversized_logged[ $key ] ) ) {
225 return;
226 }
227 self::$oversized_logged[ $key ] = true;
228 Logger::warning(
229 sprintf(
230 'WCPOS sync: dropped oversized meta "%s" (meta id %d) from the POS payload; the stored value exceeds %d nodes or %d bytes and cannot be served to the POS.',
231 $key,
232 $meta_id,
233 self::OVERSIZED_META_NODE_LIMIT,
234 self::OVERSIZED_META_BYTE_LIMIT
235 )
236 );
237 }
238
239 /**
240 * Whether a meta value is too large to serve. Walks iteratively and stops the moment a
241 * limit is crossed, so the cost is bounded by the limits, not by the value: a
242 * 16-million-element value costs the same as a 20,001-element one.
243 *
244 * EVERY object is expanded, not just stdClass. WordPress unserializes stored meta, so a
245 * value can come back as an instance of some other plugin's class, and json_encode
246 * serializes its public properties just the same — a custom object wrapping the
247 * multi-million-element array would otherwise walk straight past this check into the
248 * encode that killed the request. `get_object_vars()` is called from outside the value's
249 * class, so it sees exactly the public properties the encoder will. Traversal is by
250 * refcount, never a deep copy.
251 *
252 * Keys count toward both budgets: a value can be a few entries under enormous keys.
253 *
254 * A self-referencing object graph terminates on the node limit and reports oversized,
255 * which is correct — json_encode cannot represent one either.
256 *
257 * @param mixed $value Meta value as stored.
258 *
259 * @return bool
260 */
261 public static function exceeds_value_budget( $value ): bool {
262 $unencodable = false;
263 $value = self::as_encoded( $value, $unencodable );
264 if ( $unencodable ) {
265 return true;
266 }
267 if ( is_string( $value ) ) {
268 return \strlen( $value ) > self::OVERSIZED_META_BYTE_LIMIT;
269 }
270 if ( ! is_array( $value ) && ! \is_object( $value ) ) {
271 return false;
272 }
273
274 $nodes = 0;
275 $bytes = 0;
276 $stack = array( is_array( $value ) ? $value : get_object_vars( $value ) );
277 while ( array() !== $stack ) {
278 $current = array_pop( $stack );
279 foreach ( $current as $child_key => $child ) {
280 ++$nodes;
281 if ( is_string( $child_key ) ) {
282 $bytes += \strlen( $child_key );
283 }
284 $child = self::as_encoded( $child, $unencodable );
285 if ( $unencodable ) {
286 return true;
287 }
288 if ( is_string( $child ) ) {
289 $bytes += \strlen( $child );
290 } elseif ( is_array( $child ) ) {
291 $stack[] = $child;
292 } elseif ( \is_object( $child ) ) {
293 $stack[] = get_object_vars( $child );
294 }
295 if ( $nodes > self::OVERSIZED_META_NODE_LIMIT || $bytes > self::OVERSIZED_META_BYTE_LIMIT ) {
296 return true;
297 }
298 }
299 }
300
301 return false;
302 }
303
304 /**
305 * What `json_encode()` will actually serialize for a value.
306 *
307 * A `JsonSerializable` object is encoded from `jsonSerialize()`, NOT from its public
308 * properties, so an object can expose nothing and still return a multi-million-element
309 * array to the encoder. Budgeting `get_object_vars()` alone would wave exactly that
310 * through. The encoder is going to call this method moments later anyway, so calling it
311 * here adds no execution that was not already going to happen.
312 *
313 * A chain deeper than a handful of levels, or one that throws, is reported as
314 * unencodable and the entry is withheld: `json_encode()` would fail on it too, and
315 * failing there is the fatal this guard exists to prevent.
316 *
317 * @param mixed $value Value to resolve.
318 * @param bool $unencodable Set to true when the value cannot be resolved safely.
319 *
320 * @return mixed
321 */
322 private static function as_encoded( $value, bool &$unencodable ) {
323 $depth = 0;
324 while ( $value instanceof \JsonSerializable ) {
325 if ( ++$depth > 8 ) {
326 $unencodable = true;
327
328 return null;
329 }
330 try {
331 $value = $value->jsonSerialize();
332 } catch ( \Throwable $error ) {
333 $unencodable = true;
334
335 return null;
336 }
337 }
338
339 return $value;
340 }
341
342 /**
343 * Convert JSON objects to arrays unless doing so would change their wire shape.
344 *
345 * @param mixed $value Decoded JSON value.
346 *
347 * @return mixed
348 */
349 private static function preserve_json_object_shape( $value ) {
350 if ( is_array( $value ) ) {
351 return array_map( array( __CLASS__, __FUNCTION__ ), $value );
352 }
353
354 if ( ! $value instanceof \stdClass ) {
355 return $value;
356 }
357
358 /**
359 * Decoded object properties.
360 *
361 * @var array<int|string, mixed> $properties
362 */
363 $properties = array_map( array( __CLASS__, __FUNCTION__ ), get_object_vars( $value ) );
364
365 return array() === $properties || array_values( $properties ) === $properties
366 ? (object) $properties
367 : $properties;
368 }
369 }
370