PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.18
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.18
1.10.19 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 All 163 releases
woocommerce-pos / includes / API / V1 / Traits / WCPOS_REST_API.php

WCPOS_REST_API.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.18, at includes/API/V1/Traits/WCPOS_REST_API.php

491 lines 16.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WCPOS_REST_API.
4 *
5 * @package WCPOS\WooCommercePOS
6 */
7
8 namespace WCPOS\WooCommercePOS\API\V1\Traits;
9
10 use Automattic\WooCommerce\Utilities\OrderUtil;
11 use WC_Data;
12 use WCPOS\WooCommercePOS\Logger;
13 use WCPOS\WooCommercePOS\Services\Barcode_Field;
14 use WCPOS\WooCommercePOS\Services\Pos_Order_Audit;
15 use WCPOS\WooCommercePOS\Services\Settings;
16 use WCPOS\WooCommercePOS\Sync\Meta_Normalizer;
17 use WCPOS\WooCommercePOS\Sync\Pos_Uuid;
18 use WP_Error;
19 use WP_REST_Request;
20 use WP_REST_Response;
21 use Exception;
22
23 /**
24 * Shared helpers for all WCPOS REST API controllers.
25 */
26 trait WCPOS_REST_API {
27 /**
28 * Drop malformed meta_data entries from a create/update request.
29 *
30 * Batch requests bypass per-item schema validation (WC's batch_items() calls
31 * create_item()/update_item() directly), and WC core's meta_data writes read
32 * $meta['key'] / $meta['value'] unguarded (verified through WC 10.4) — a
33 * malformed entry throws a TypeError on PHP 8, which 500s the batch after
34 * earlier items already persisted, so a client retry risks duplicate
35 * records. A present-but-malformed '_woocommerce_pos_uuid' is rejected with
36 * a 400 instead of dropped: dropping the client's dedupe key would mint a
37 * fresh server uuid on every retry — the duplicate-record outcome this
38 * sanitization exists to prevent — while a rejected item creates nothing
39 * and surfaces a visible per-item error.
40 *
41 * LANE SCOPE — v1 ONLY, deliberately NOT ported to v2 (lane audit 2026-08-10).
42 * This tolerance exists because a v1 BATCH is many records in one request, so
43 * one malformed entry could 500 the batch after earlier items had already
44 * persisted. The v2 push lane is one mutation per request: there is no
45 * half-applied batch to protect, so it forwards the payload and lets wc/v3's
46 * schema validation reject it, which surfaces a precise per-mutation error
47 * instead of silently discarding metadata. That divergence is a TRANSPORT
48 * difference, not lost parity, and is pinned from the v2 side by
49 * Test_Write_Controller::test_malformed_order_meta_data_passes_through_to_woo_validation().
50 * The v1 tests naming `not-an-object` are legacy pins for THIS lane; do not
51 * "restore" this behaviour on v2.
52 *
53 * @param WP_REST_Request $request Full details about the request.
54 *
55 * @return WP_Error|null WP_Error for a malformed POS uuid entry, null otherwise.
56 */
57 protected function wcpos_sanitize_meta_data_param( WP_REST_Request $request ) {
58 if ( ! isset( $request['meta_data'] ) || ! \is_array( $request['meta_data'] ) ) {
59 return null;
60 }
61
62 $sanitized = array();
63 foreach ( $request['meta_data'] as $meta ) {
64 $key = \is_array( $meta ) && isset( $meta['key'] ) && \is_scalar( $meta['key'] ) ? (string) $meta['key'] : null;
65 if ( '_woocommerce_pos_uuid' === $key && ( ! isset( $meta['value'] ) || ! Pos_Uuid::is_uuid( $meta['value'] ) ) ) {
66 return new WP_Error(
67 'woocommerce_pos_rest_invalid_uuid',
68 __( 'Invalid _woocommerce_pos_uuid meta_data value.', 'woocommerce-pos' ),
69 array( 'status' => 400 )
70 );
71 }
72 if ( null === $key || ! array_key_exists( 'value', $meta ) ) {
73 continue;
74 }
75 $sanitized[] = $meta;
76 }
77
78 $request['meta_data'] = $sanitized;
79
80 return null;
81 }
82
83 /**
84 * Formats the response for all fetched posts into associative arrays.
85 *
86 * @param array $results The raw results from the database query.
87 *
88 * @return array An array of associative arrays with post information.
89 */
90 public function wcpos_format_all_posts_response( $results ) {
91 /**
92 * Performance notes:
93 * - Using a generator is faster than array_map when dealing with large datasets.
94 * - If date is in the format 'Y-m-d H:i:s' we just do preg_replace to 'Y-m-d\TH:i:s', rather than using wc_rest_prepare_date_response
95 *
96 * This resulted in execution time of 10% of the original time.
97 */
98 return iterator_to_array(
99 ( function () use ( $results ) {
100 foreach ( $results as $result ) {
101 $result['id'] = (int) $result['id'];
102
103 if ( isset( $result['date_modified_gmt'] ) ) {
104 if ( preg_match( '/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/', $result['date_modified_gmt'] ) ) {
105 $result['date_modified_gmt'] = preg_replace( '/(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})/', '$1T$2', $result['date_modified_gmt'] );
106 } else {
107 $result['date_modified_gmt'] = wc_rest_prepare_date_response( $result['date_modified_gmt'] );
108 }
109 }
110
111 yield $result;
112 }
113 } )()
114 );
115 }
116
117 /**
118 * BUG FIX: some servers are not returning the correct meta_data if it is left as WC_Meta_Data objects
119 * NOTE: it only seems to effect some versions of PHP, or some plugins are adding weird meta_data types
120 * The result is mata_data: [{}, {}, {}] ie: empty objects, I think json_encode can't handle the WC_Meta_Data objects.
121 *
122 * @param WC_Data $object The WC_Data object to parse meta from.
123 *
124 * @return array
125 */
126 public function wcpos_parse_meta_data( WC_Data $object ): array {
127 $raw_meta = $object->get_meta_data();
128 $meta_data = array();
129 $dropped = false;
130
131 foreach ( $raw_meta as $index => $meta ) {
132 // One monstrous value is what kills the request, and the count monitor below
133 // cannot see it: a record can hold a single meta entry that serializes to a
134 // gigabyte, sail past every threshold on the NUMBER of entries, and then fatal
135 // the response encoder. Measure the value and withhold it, same budget as the
136 // v2 sync lane applies in Meta_Normalizer.
137 if ( Meta_Normalizer::exceeds_value_budget( $meta->value ) ) {
138 Meta_Normalizer::note_oversized_meta( (string) $meta->key, (int) $meta->id );
139 $dropped = true;
140 continue;
141 }
142
143 $meta_data[ $index ] = array_merge(
144 $meta->get_data(),
145 array(
146 'key' => $meta->key,
147 'value' => $meta->value,
148 )
149 );
150 }
151
152 if ( $dropped ) {
153 // A list with a hole JSON-encodes as an object; the wire expects a list.
154 $meta_data = array_values( $meta_data );
155 }
156
157 // Monitor meta count and log if thresholds exceeded.
158 $this->wcpos_monitor_meta_count( $object, $raw_meta );
159
160 return $meta_data;
161 }
162
163 /**
164 * Monitor meta_data count and log warnings/errors when thresholds are exceeded.
165 *
166 * Uses a static array to throttle logging: one log per object per request lifecycle.
167 *
168 * @param WC_Data $object The WC_Data object.
169 * @param array $raw_meta Array of WC_Meta_Data objects.
170 */
171 private function wcpos_monitor_meta_count( WC_Data $object, array $raw_meta ): void {
172 static $logged_ids = array();
173
174 $count = \count( $raw_meta );
175 $id = $object->get_id();
176
177 // Throttle: one log per object per request.
178 $key = \get_class( $object ) . '_' . $id;
179 if ( isset( $logged_ids[ $key ] ) ) {
180 return;
181 }
182
183 $warning_threshold = (int) apply_filters( 'woocommerce_pos_meta_data_warning_threshold', 50 );
184 $error_threshold = (int) apply_filters( 'woocommerce_pos_meta_data_error_threshold', 500 );
185 $include_top_keys = (bool) apply_filters( 'woocommerce_pos_meta_data_log_top_keys', false, $object, $count );
186 $context = $include_top_keys ? 'Top meta keys: ' . $this->wcpos_get_top_meta_keys( $raw_meta ) : null;
187
188 if ( $count >= $error_threshold ) {
189 $logged_ids[ $key ] = true;
190 $type = $this->wcpos_get_object_type_label( $object );
191 Logger::error(
192 "{$type} #{$id} has {$count} meta_data entries (threshold: {$error_threshold}). This is likely causing performance issues.",
193 $context
194 );
195 } elseif ( $count >= $warning_threshold ) {
196 $logged_ids[ $key ] = true;
197 $type = $this->wcpos_get_object_type_label( $object );
198 Logger::warning(
199 "{$type} #{$id} has {$count} meta_data entries (threshold: {$warning_threshold}). This may indicate plugin meta bloat.",
200 $context
201 );
202 }
203 }
204
205 /**
206 * Get a human-readable label for a WC_Data object type.
207 *
208 * @param WC_Data $object The WC_Data object.
209 *
210 * @return string
211 */
212 private function wcpos_get_object_type_label( WC_Data $object ): string {
213 if ( $object instanceof \WC_Order ) {
214 return 'Order';
215 }
216 if ( $object instanceof \WC_Product_Variation ) {
217 return 'Variation';
218 }
219 if ( $object instanceof \WC_Product ) {
220 return 'Product';
221 }
222 if ( $object instanceof \WC_Customer ) {
223 return 'Customer';
224 }
225
226 return 'Object';
227 }
228
229 /**
230 * Get a string of the top 10 most common meta keys and their counts.
231 *
232 * @param array $raw_meta Array of WC_Meta_Data objects.
233 *
234 * @return string Formatted string like "_yoast_seo (12), _elementor_data (8), ..."
235 */
236 private function wcpos_get_top_meta_keys( array $raw_meta ): string {
237 $counts = array();
238 foreach ( $raw_meta as $meta ) {
239 $meta_key = $meta->key;
240 if ( ! isset( $counts[ $meta_key ] ) ) {
241 $counts[ $meta_key ] = 0;
242 }
243 ++$counts[ $meta_key ];
244 }
245 arsort( $counts );
246 $top = \array_slice( $counts, 0, 10, true );
247
248 $parts = array();
249 foreach ( $top as $meta_key => $cnt ) {
250 $parts[] = "{$meta_key} ({$cnt})";
251 }
252
253 return implode( ', ', $parts );
254 }
255
256 /**
257 * Estimate the response size and log if it exceeds thresholds.
258 *
259 * Uses a lightweight calculation instead of serialize() to avoid doubling memory usage.
260 *
261 * @param array $data The response data array.
262 * @param int $id The object ID.
263 * @param string $type The object type label (e.g. 'Product', 'Order').
264 */
265 public function wcpos_estimate_response_size( array $data, int $id, string $type ): void {
266 static $logged_ids = array();
267
268 $key = $type . '_' . $id;
269 if ( isset( $logged_ids[ $key ] ) ) {
270 return;
271 }
272
273 // Estimate: meta_count * 200 bytes + string field lengths.
274 $meta_count = isset( $data['meta_data'] ) ? \count( $data['meta_data'] ) : 0;
275 $estimated_size = $meta_count * 200;
276
277 // Add string field sizes.
278 $string_fields = array( 'description', 'short_description', 'content' );
279 foreach ( $string_fields as $field ) {
280 if ( isset( $data[ $field ] ) && \is_string( $data[ $field ] ) ) {
281 $estimated_size += \strlen( $data[ $field ] );
282 }
283 }
284
285 $warning_threshold = (int) apply_filters( 'woocommerce_pos_response_size_warning_threshold', 100000 );
286 $error_threshold = (int) apply_filters( 'woocommerce_pos_response_size_error_threshold', 500000 );
287
288 if ( $estimated_size >= $error_threshold ) {
289 $logged_ids[ $key ] = true;
290 $size_kb = round( $estimated_size / 1024, 1 );
291 $threshold_kb = round( $error_threshold / 1024, 1 );
292 Logger::error( "{$type} #{$id} estimated response size {$size_kb}KB exceeds {$threshold_kb}KB threshold." );
293 } elseif ( $estimated_size >= $warning_threshold ) {
294 $logged_ids[ $key ] = true;
295 $size_kb = round( $estimated_size / 1024, 1 );
296 $threshold_kb = round( $warning_threshold / 1024, 1 );
297 Logger::warning( "{$type} #{$id} estimated response size {$size_kb}KB exceeds {$threshold_kb}KB threshold." );
298 }
299 }
300
301 /**
302 * Pre-flight check: count meta entries for an object before WC loads it.
303 *
304 * Runs a cheap SELECT COUNT(*) query. Callers should check the return value
305 * and bypass WC's response pipeline if the count exceeds the error threshold.
306 *
307 * @param int $object_id The object ID.
308 * @param string $object_type One of 'post', 'order', 'user'.
309 *
310 * @return int The meta count.
311 */
312 public function wcpos_preflight_meta_count( int $object_id, string $object_type = 'post' ): int {
313 global $wpdb;
314
315 switch ( $object_type ) {
316 case 'order':
317 if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) {
318 $table = "{$wpdb->prefix}wc_orders_meta";
319 $column = 'order_id';
320 } else {
321 $table = $wpdb->postmeta;
322 $column = 'post_id';
323 }
324 break;
325
326 case 'user':
327 $table = $wpdb->usermeta;
328 $column = 'user_id';
329 break;
330
331 default:
332 $table = $wpdb->postmeta;
333 $column = 'post_id';
334 break;
335 }
336
337 $count = (int) $wpdb->get_var(
338 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are safe hardcoded values.
339 $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE {$column} = %d", $object_id )
340 );
341
342 return $count;
343 }
344
345 /**
346 * Get only the essential POS meta keys for an object when the full meta load would OOM.
347 *
348 * @param int $object_id The object ID.
349 * @param string $object_type One of 'post', 'order', 'user'.
350 * @param array $extra_keys Additional meta keys to include.
351 *
352 * @return array Array of meta entries in WC REST format [{id, key, value}, ...].
353 */
354 public function wcpos_get_essential_meta( int $object_id, string $object_type = 'post', array $extra_keys = array() ): array {
355 global $wpdb;
356
357 // Base essential key present for all object types.
358 $keys = array( '_woocommerce_pos_uuid' );
359 $keys = array_merge( $keys, $extra_keys );
360
361 // Build LIKE patterns for wildcard pro keys (products/variations only).
362 $like_patterns = array();
363
364 switch ( $object_type ) {
365 case 'order':
366 if ( class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled() ) {
367 $table = "{$wpdb->prefix}wc_orders_meta";
368 $id_col = 'order_id';
369 $meta_id = 'id';
370 } else {
371 $table = $wpdb->postmeta;
372 $id_col = 'post_id';
373 $meta_id = 'meta_id';
374 }
375 // The audit keys come from the shared authority so a new audit key
376 // reaches this degraded-meta read path without a second edit.
377 $keys = array_merge(
378 $keys,
379 Pos_Order_Audit::audit_meta_keys(),
380 array(
381 '_woocommerce_pos_tax_based_on',
382 )
383 );
384 break;
385
386 case 'user':
387 $table = $wpdb->usermeta;
388 $id_col = 'user_id';
389 $meta_id = 'umeta_id';
390 break;
391
392 default: // post (products, variations).
393 $table = $wpdb->postmeta;
394 $id_col = 'post_id';
395 $meta_id = 'meta_id';
396
397 // Add barcode field if it's a custom meta key. The two native keys
398 // are product properties, not postmeta this allowlist can carry.
399 // No empty check: Barcode_Field::meta_key() never returns ''.
400 $barcode_field = Barcode_Field::meta_key();
401 if ( '_sku' !== $barcode_field && Barcode_Field::DEFAULT_FIELD !== $barcode_field ) {
402 $keys[] = $barcode_field;
403 }
404 $keys[] = '_woocommerce_pos_variable_prices';
405
406 // Pro store-specific pricing keys use wildcard patterns.
407 $like_patterns = array(
408 '_pos_price%',
409 '_pos_regular_price%',
410 '_pos_sale_price%',
411 '_pos_tax_status%',
412 '_pos_tax_class%',
413 '_pos_price_fields%',
414 '_pos_tax_fields%',
415 );
416 break;
417 }
418
419 $keys = array_unique( $keys );
420
421 // Build the WHERE clause.
422 $placeholders = implode( ', ', array_fill( 0, \count( $keys ), '%s' ) );
423 $prepare_args = array_merge( array( $object_id ), $keys );
424
425 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are safe hardcoded values.
426 $where = $wpdb->prepare( "{$id_col} = %d AND meta_key IN ({$placeholders})", $prepare_args );
427
428 // Add LIKE patterns for wildcard keys.
429 foreach ( $like_patterns as $pattern ) {
430 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are safe hardcoded values.
431 $where .= $wpdb->prepare( " OR ({$id_col} = %d AND meta_key LIKE %s)", $object_id, $pattern );
432 }
433
434 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- built safely above.
435 $results = $wpdb->get_results( "SELECT {$meta_id} as meta_id, meta_key, meta_value FROM {$table} WHERE {$where}" );
436
437 if ( ! $results ) {
438 return array();
439 }
440
441 return array_map(
442 function ( $row ) {
443 return array(
444 'id' => (int) $row->meta_id,
445 'key' => $row->meta_key,
446 'value' => maybe_unserialize( $row->meta_value ),
447 );
448 },
449 $results
450 );
451 }
452
453 /**
454 * Whether decimal stock/cart quantities are enabled.
455 *
456 * @return bool
457 */
458 public function wcpos_allow_decimal_quantities() {
459 return Settings::instance()->decimal_qty_enabled();
460 }
461
462 /**
463 * Get server load average.
464 *
465 * @return array The load average.
466 */
467 public function get_server_load() {
468 try {
469 if ( stristr( PHP_OS, 'win' ) ) {
470 // Use WMIC to get load percentage from Windows.
471 $load = @shell_exec( 'wmic cpu get loadpercentage /all' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
472 if ( $load ) {
473 $load = explode( "\n", $load );
474 if ( isset( $load[1] ) ) {
475 $load = intval( $load[1] );
476 return array( $load, $load, $load ); // Mimic the array structure of sys_getloadavg().
477 }
478 }
479 } elseif ( function_exists( 'sys_getloadavg' ) ) {
480 return sys_getloadavg();
481 }
482 } catch ( Exception $e ) {
483 // Log the error for debugging purposes.
484 Logger::log( 'Error getting server load: ' . $e->getMessage() );
485 }
486
487 // Fallback if no method is available or an error occurs.
488 return array( 0, 0, 0 );
489 }
490 }
491