PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.3
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.3
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.3, at includes/API/V1/Traits/WCPOS_REST_API.php

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