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

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

662 lines 18.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Logs REST API controller.
4 *
5 * Surfaces POS log entries for the settings screen.
6 *
7 * @package WCPOS\WooCommercePOS\API\V1
8 */
9
10 namespace WCPOS\WooCommercePOS\API\V1;
11
12 use WCPOS\WooCommercePOS\Services\Extensions;
13 use WP_REST_Controller;
14 use WP_REST_Request;
15 use WP_REST_Response;
16 use WP_REST_Server;
17
18 use const WCPOS\WooCommercePOS\SHORT_NAME;
19
20 /**
21 * Logs controller class.
22 */
23 class Logs extends WP_REST_Controller {
24
25 /**
26 * User meta key for last-viewed timestamp.
27 */
28 const LAST_VIEWED_META_KEY = '_wcpos_logs_last_viewed';
29
30 /**
31 * Route namespace.
32 *
33 * @var string
34 */
35 protected $namespace = SHORT_NAME . '/v1';
36
37 /**
38 * Route base.
39 *
40 * @var string
41 */
42 protected $rest_base = 'logs';
43
44 /**
45 * Core log source written to by the free plugin (and Pro, which reuses it).
46 *
47 * @var string
48 */
49 const CORE_SOURCE = 'woocommerce-pos';
50
51 /**
52 * Register routes.
53 *
54 * @return void
55 */
56 public function register_routes(): void {
57 register_rest_route(
58 $this->namespace,
59 '/' . $this->rest_base,
60 array(
61 'methods' => WP_REST_Server::READABLE,
62 'callback' => array( $this, 'get_items' ),
63 'permission_callback' => array( $this, 'check_permissions' ),
64 )
65 );
66
67 register_rest_route(
68 $this->namespace,
69 '/' . $this->rest_base . '/mark-read',
70 array(
71 'methods' => WP_REST_Server::CREATABLE,
72 'callback' => array( $this, 'mark_read' ),
73 'permission_callback' => array( $this, 'check_permissions' ),
74 )
75 );
76 }
77
78 /**
79 * Get log entries.
80 *
81 * Detects whether the store uses file-based or database logging,
82 * then returns parsed entries in reverse chronological order.
83 *
84 * @param WP_REST_Request $request Request object.
85 *
86 * @return WP_REST_Response
87 */
88 public function get_items( $request ): WP_REST_Response {
89 $level = $request->get_param( 'level' );
90 $per_page = max( 1, (int) ( $request->get_param( 'per_page' ) ? $request->get_param( 'per_page' ) : 50 ) );
91 $page = max( 1, (int) ( $request->get_param( 'page' ) ? $request->get_param( 'page' ) : 1 ) );
92 $available = $this->get_available_sources();
93 $allowed_slugs = array_column( $available, 'source' );
94 $raw_source = trim( (string) ( $request->get_param( 'source' ) ?? 'all' ) );
95
96 // Resolve the requested source against the allowlist. Empty or 'all'
97 // means every allowed slug; a valid slug is allowed through; anything
98 // else falls back to the core source.
99 if ( '' === $raw_source || 'all' === $raw_source ) {
100 $sources = $allowed_slugs;
101 } elseif ( \in_array( $raw_source, $allowed_slugs, true ) ) {
102 $sources = array( $raw_source );
103 } else {
104 $sources = array( self::CORE_SOURCE );
105 }
106
107 if ( 'database' === $this->get_handler_type() ) {
108 $entries = $this->get_db_entries( $level, $sources );
109 } else {
110 $entries = $this->get_file_entries( $sources );
111
112 // Filter by level if specified (DB does this in SQL).
113 if ( $level ) {
114 $entries = array_values(
115 array_filter(
116 $entries,
117 function ( $entry ) use ( $level ) {
118 return strtolower( $level ) === $entry['level'];
119 }
120 )
121 );
122 }
123 }
124
125 $total = count( $entries );
126 $total_pages = max( 1, (int) ceil( $total / $per_page ) );
127 $offset = ( $page - 1 ) * $per_page;
128 $entries = array_slice( $entries, $offset, $per_page );
129
130 $response = new WP_REST_Response(
131 array(
132 'entries' => $entries,
133 'has_fatal_errors' => $this->has_fatal_errors(),
134 'fatal_errors_url' => $this->get_fatal_errors_url(),
135 'sources' => $available,
136 )
137 );
138
139 $response->header( 'X-WP-Total', (string) $total );
140 $response->header( 'X-WP-TotalPages', (string) $total_pages );
141
142 return $response;
143 }
144
145 /**
146 * Build the list of log sources the UI may filter by.
147 *
148 * Always includes the core `woocommerce-pos` source. Each installed
149 * catalog extension that declares a `log_source` adds an entry so its logs
150 * can be inspected alongside the core plugin's.
151 *
152 * @return array<int, array{source: string, name: string, requires_pro: bool, is_core: bool}>
153 */
154 private function get_available_sources(): array {
155 $sources = array(
156 array(
157 'source' => self::CORE_SOURCE,
158 'name' => 'WCPOS',
159 'requires_pro' => false,
160 'is_core' => true,
161 ),
162 );
163
164 if ( ! class_exists( Extensions::class ) ) {
165 return $sources;
166 }
167
168 foreach ( Extensions::instance()->get_extensions() as $entry ) {
169 $log_source = $entry['log_source'] ?? '';
170 $status = $entry['status'] ?? 'not_installed';
171
172 if ( ! \is_string( $log_source ) || '' === $log_source || 'not_installed' === $status ) {
173 continue;
174 }
175
176 if ( self::CORE_SOURCE === $log_source ) {
177 continue;
178 }
179
180 $sources[] = array(
181 'source' => $log_source,
182 'name' => (string) ( $entry['name'] ?? $log_source ),
183 'requires_pro' => (bool) ( $entry['requires_pro'] ?? false ),
184 'is_core' => false,
185 );
186 }
187
188 return $sources;
189 }
190
191 /**
192 * Maximum number of log entries to parse from files to prevent memory exhaustion.
193 */
194 const MAX_FILE_ENTRIES = 10000;
195
196 /**
197 * Parse log entries from file-based handler.
198 *
199 * Scans wc-logs/ for {source}-*.log files per requested source and parses
200 * each line. Reads files line-by-line to avoid loading entire files into
201 * memory. Processes newest files first and caps at MAX_FILE_ENTRIES total.
202 *
203 * Note: within each file, lines are read top-to-bottom (oldest first).
204 * If the cap is hit mid-file, the newest entries in that file are lost.
205 * The final result is sorted by timestamp, so ordering is still correct
206 * for the entries that are returned.
207 *
208 * @param array<int, string> $sources Allowed log-source slugs (pre-validated).
209 *
210 * @return array<int, array{timestamp: string, level: string, message: string, context: string, source: string}>
211 */
212 private function get_file_entries( array $sources ): array {
213 if ( empty( $sources ) ) {
214 return array();
215 }
216
217 $log_dir = trailingslashit( wp_upload_dir()['basedir'] ) . 'wc-logs/';
218 $files = array();
219
220 foreach ( $sources as $source ) {
221 // WC log filenames: {source}-YYYY-MM-DD-{hash}.log.
222 $matched = glob( $log_dir . $source . '-*.log' );
223 if ( empty( $matched ) ) {
224 continue;
225 }
226
227 // Anchor on "{source}-YYYY-MM-DD-" so we don't over-match when one
228 // source is a prefix of another (e.g. "woocommerce-pos" vs
229 // "woocommerce-pos-foo").
230 $pattern = '/^' . preg_quote( $source, '/' ) . '-\d{4}-\d{2}-\d{2}-/';
231
232 foreach ( $matched as $file ) {
233 if ( 1 !== preg_match( $pattern, basename( $file ) ) ) {
234 continue;
235 }
236 $files[] = array(
237 'path' => $file,
238 'source' => $source,
239 );
240 }
241 }
242
243 if ( empty( $files ) ) {
244 return array();
245 }
246
247 // Sort files by modification time descending so newest logs are processed first.
248 usort(
249 $files,
250 function ( $a, $b ) {
251 return filemtime( $b['path'] ) - filemtime( $a['path'] );
252 }
253 );
254
255 $entries = array();
256 $count = 0;
257
258 foreach ( $files as $file ) {
259 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading local log files.
260 $handle = fopen( $file['path'], 'r' );
261 if ( ! $handle ) {
262 continue;
263 }
264
265 while ( false !== ( $line = fgets( $handle ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
266 $entry = $this->parse_log_line( $line );
267 if ( $entry ) {
268 $entry['source'] = $file['source'];
269 $entries[] = $entry;
270 ++$count;
271
272 if ( $count >= self::MAX_FILE_ENTRIES ) {
273 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
274 fclose( $handle );
275 break 2;
276 }
277 }
278 }
279
280 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
281 fclose( $handle );
282 }
283
284 // Sort by timestamp descending (newest first).
285 usort(
286 $entries,
287 function ( $a, $b ) {
288 return strcmp( $b['timestamp'], $a['timestamp'] );
289 }
290 );
291
292 return $entries;
293 }
294
295 /**
296 * Detect which log handler type is active.
297 *
298 * @return string 'file' or 'database'
299 */
300 private function get_handler_type(): string {
301 /**
302 * Filter the detected log handler type.
303 *
304 * @param string|null $type 'file' or 'database', or null for auto-detection.
305 */
306 $type = apply_filters( 'woocommerce_pos_log_handler_type', null );
307 if ( $type ) {
308 return $type;
309 }
310
311 $handler = get_option( 'woocommerce_default_log_handler', '' );
312
313 if ( false !== strpos( $handler, 'DB' ) || false !== strpos( $handler, 'Database' ) ) {
314 return 'database';
315 }
316
317 return 'file';
318 }
319
320 /**
321 * Get log entries from the database handler.
322 *
323 * @param string|null $level Optional level filter.
324 * @param array<int, string> $sources Allowed log-source slugs (pre-validated).
325 *
326 * @return array
327 */
328 private function get_db_entries( ?string $level, array $sources ): array {
329 global $wpdb;
330
331 if ( empty( $sources ) ) {
332 return array();
333 }
334
335 $table = $wpdb->prefix . 'woocommerce_log';
336
337 // Check table exists.
338 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
339 if ( ! $table_exists ) {
340 return array();
341 }
342
343 $placeholders = implode( ', ', array_fill( 0, count( $sources ), '%s' ) );
344 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholders are generated from count($sources).
345 $where = $wpdb->prepare( "WHERE source IN ({$placeholders})", ...$sources );
346
347 if ( $level ) {
348 $severity = \WC_Log_Levels::get_level_severity( $level );
349 $where .= $wpdb->prepare( ' AND level = %d', $severity );
350 }
351
352 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is safe prefix, $where is prepared above.
353 $sql = "SELECT timestamp, level, message, source FROM {$table} {$where} ORDER BY timestamp DESC LIMIT 10000";
354 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is safely constructed above with $wpdb->prepare().
355 $results = $wpdb->get_results( $sql, ARRAY_A );
356
357 if ( empty( $results ) ) {
358 return array();
359 }
360
361 $level_map = array_flip(
362 array(
363 'emergency' => 800,
364 'alert' => 700,
365 'critical' => 600,
366 'error' => 500,
367 'warning' => 400,
368 'notice' => 300,
369 'info' => 200,
370 'debug' => 100,
371 )
372 );
373
374 return array_map(
375 function ( $row ) use ( $level_map ) {
376 $message = $row['message'];
377 $context = '';
378
379 $context_pos = strpos( $message, ' | Context: ' );
380 if ( false !== $context_pos ) {
381 $context = substr( $message, $context_pos + 12 );
382 $message = substr( $message, 0, $context_pos );
383 }
384
385 // MySQL DATETIME ('YYYY-MM-DD HH:MM:SS' in GMT) is not portable
386 // ISO 8601 — Safari/WebKit rejects it in `new Date(...)`. Emit
387 // RFC3339 UTC so the frontend can parse it consistently with
388 // file-based entries.
389 $timestamp_utc = strtotime( $row['timestamp'] . ' UTC' );
390 $timestamp = false === $timestamp_utc
391 ? $row['timestamp']
392 : gmdate( 'c', $timestamp_utc );
393
394 return array(
395 'timestamp' => $timestamp,
396 'level' => $level_map[ (int) $row['level'] ] ?? 'debug',
397 'message' => $message,
398 'context' => $context,
399 'source' => (string) ( $row['source'] ?? self::CORE_SOURCE ),
400 );
401 },
402 $results
403 );
404 }
405
406 /**
407 * Parse a single WC log line into a structured entry.
408 *
409 * WC log format: "TIMESTAMP LEVEL message"
410 * Context is appended after " | Context: "
411 *
412 * @param string $line Raw log line.
413 *
414 * @return array{timestamp: string, level: string, message: string, context: string}|null
415 */
416 private function parse_log_line( string $line ): ?array {
417 $line = trim( $line );
418 if ( '' === $line ) {
419 return null;
420 }
421
422 // Match: timestamp (ISO 8601), level (word), rest is message.
423 if ( ! preg_match( '/^(\S+)\s+(EMERGENCY|ALERT|CRITICAL|ERROR|WARNING|NOTICE|INFO|DEBUG)\s+(.*)$/i', $line, $matches ) ) {
424 return null;
425 }
426
427 $message = $matches[3];
428 $context = '';
429
430 // Split context from message.
431 $context_pos = strpos( $message, ' | Context: ' );
432 if ( false !== $context_pos ) {
433 $context = substr( $message, $context_pos + 12 );
434 $message = substr( $message, 0, $context_pos );
435 }
436
437 return array(
438 'timestamp' => $matches[1],
439 'level' => strtolower( $matches[2] ),
440 'message' => $message,
441 'context' => $context,
442 );
443 }
444
445 /**
446 * Check if fatal-errors log files exist.
447 *
448 * @return bool
449 */
450 private function has_fatal_errors(): bool {
451 $log_dir = trailingslashit( wp_upload_dir()['basedir'] ) . 'wc-logs/';
452 $files = glob( $log_dir . 'fatal-errors-*.log' );
453
454 return ! empty( $files );
455 }
456
457 /**
458 * Get the URL to WooCommerce's log viewer filtered to fatal errors.
459 *
460 * @return string
461 */
462 private function get_fatal_errors_url(): string {
463 return admin_url( 'admin.php?page=wc-status&tab=logs&source=fatal-errors' );
464 }
465
466 /**
467 * Mark logs as read for the current user.
468 *
469 * Stores the current timestamp in user meta.
470 *
471 * @param WP_REST_Request $request Request object.
472 *
473 * @return WP_REST_Response
474 */
475 public function mark_read( WP_REST_Request $request ): WP_REST_Response {
476 $timestamp = gmdate( 'c' );
477 update_user_meta( get_current_user_id(), self::LAST_VIEWED_META_KEY, $timestamp );
478
479 return new WP_REST_Response(
480 array(
481 'success' => true,
482 'timestamp' => $timestamp,
483 )
484 );
485 }
486
487 /**
488 * Get unread error/warning counts for a user.
489 *
490 * This is a static method so it can be called from the Settings page
491 * to inject initial counts into the inline script.
492 *
493 * @param int $user_id User ID.
494 *
495 * @return array{error: int, warning: int}
496 */
497 public static function get_unread_counts( int $user_id ): array {
498 $last_viewed = get_user_meta( $user_id, self::LAST_VIEWED_META_KEY, true );
499 $last_viewed_ts = $last_viewed ? strtotime( $last_viewed ) : null;
500
501 $instance = new self();
502
503 if ( 'database' === $instance->get_handler_type() ) {
504 return $instance->get_db_unread_counts( $last_viewed_ts );
505 }
506
507 return $instance->get_file_unread_counts( $last_viewed_ts );
508 }
509
510 /**
511 * Count unread errors/warnings from database handler using SQL aggregation.
512 *
513 * @param int|null $last_viewed_ts Unix timestamp of last viewed, or null if never viewed.
514 *
515 * @return array{error: int, warning: int}
516 */
517 private function get_db_unread_counts( ?int $last_viewed_ts ): array {
518 global $wpdb;
519
520 $table = $wpdb->prefix . 'woocommerce_log';
521
522 // Check table exists.
523 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
524 if ( ! $table_exists ) {
525 return array(
526 'error' => 0,
527 'warning' => 0,
528 );
529 }
530
531 // Warning level = 400, error/critical/emergency/alert = 500-800.
532 $where = $wpdb->prepare( 'WHERE source = %s AND level >= %d', self::CORE_SOURCE, 400 );
533
534 if ( $last_viewed_ts ) {
535 $last_viewed_date = gmdate( 'Y-m-d H:i:s', $last_viewed_ts );
536 $where .= $wpdb->prepare( ' AND timestamp > %s', $last_viewed_date );
537 }
538
539 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is safe prefix, $where is prepared above.
540 $sql = "SELECT level, COUNT(*) as cnt FROM {$table} {$where} GROUP BY level";
541 // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- SQL is safely constructed above with $wpdb->prepare().
542 $results = $wpdb->get_results( $sql, ARRAY_A );
543
544 $counts = array(
545 'error' => 0,
546 'warning' => 0,
547 );
548
549 foreach ( $results as $row ) {
550 $severity = (int) $row['level'];
551 // Warning = 400, everything above is error-class.
552 if ( 400 === $severity ) {
553 $counts['warning'] += (int) $row['cnt'];
554 } else {
555 $counts['error'] += (int) $row['cnt'];
556 }
557 }
558
559 return $counts;
560 }
561
562 /**
563 * Count unread errors/warnings from file-based handler by streaming.
564 *
565 * Reads log files line-by-line without loading all entries into memory.
566 *
567 * @param int|null $last_viewed_ts Unix timestamp of last viewed, or null if never viewed.
568 *
569 * @return array{error: int, warning: int}
570 */
571 private function get_file_unread_counts( ?int $last_viewed_ts ): array {
572 $log_dir = trailingslashit( wp_upload_dir()['basedir'] ) . 'wc-logs/';
573 $files = glob( $log_dir . 'woocommerce-pos-*.log' );
574
575 $counts = array(
576 'error' => 0,
577 'warning' => 0,
578 );
579 $error_levels = array( 'error', 'critical', 'emergency', 'alert' );
580
581 if ( empty( $files ) ) {
582 return $counts;
583 }
584
585 // Sort files newest-first so the cap preserves recent entries.
586 usort(
587 $files,
588 function ( $a, $b ) {
589 return filemtime( $b ) - filemtime( $a );
590 }
591 );
592
593 $matched = 0;
594
595 foreach ( $files as $file ) {
596 // Skip files older than last_viewed based on modification time.
597 if ( $last_viewed_ts && filemtime( $file ) <= $last_viewed_ts ) {
598 continue;
599 }
600
601 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading local log files.
602 $handle = fopen( $file, 'r' );
603 if ( ! $handle ) {
604 continue;
605 }
606
607 while ( false !== ( $line = fgets( $handle ) ) ) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
608 $line = trim( $line );
609 if ( '' === $line ) {
610 continue;
611 }
612
613 // Quick regex to extract just timestamp and level without full parsing.
614 if ( ! preg_match( '/^(\S+)\s+(EMERGENCY|ALERT|CRITICAL|ERROR|WARNING)\s/i', $line, $matches ) ) {
615 continue;
616 }
617
618 $entry_level = strtolower( $matches[2] );
619 $entry_ts = strtotime( $matches[1] );
620
621 if ( $last_viewed_ts && $entry_ts && $entry_ts <= $last_viewed_ts ) {
622 continue;
623 }
624
625 $level_key = in_array( $entry_level, $error_levels, true ) ? 'error' : 'warning';
626 ++$counts[ $level_key ];
627
628 ++$matched;
629 if ( $matched >= self::MAX_FILE_ENTRIES ) {
630 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
631 fclose( $handle );
632 break 2;
633 }
634 }
635
636 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
637 fclose( $handle );
638 }
639
640 return $counts;
641 }
642
643 /**
644 * Check if the current user has permission.
645 *
646 * @param WP_REST_Request $request Request object.
647 *
648 * @return bool|\WP_Error
649 */
650 public function check_permissions( WP_REST_Request $request ) {
651 if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
652 return new \WP_Error(
653 'rest_forbidden',
654 __( 'You do not have permission to view logs.', 'woocommerce-pos' ),
655 array( 'status' => rest_authorization_required_code() )
656 );
657 }
658
659 return true;
660 }
661 }
662