PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.4
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.4
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / code-blue / log-reader.php

log-reader.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.4, at includes/code-blue/log-reader.php

516 lines 17.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Code Blue: log discovery, tailing, and parsing.
4 *
5 * Pure functions, no hooks registered here beyond the filters they
6 * expose. The REST layer (rest.php) is the only caller, so nothing
7 * in this file runs on a normal request.
8 *
9 * @package OpenStation
10 */
11
12 defined( 'ABSPATH' ) || exit;
13
14 /**
15 * Whether the current user may use the Code Blue window.
16 *
17 * Two gates, both required:
18 *
19 * 1. Capability. Error logs leak absolute paths, SQL fragments, and
20 * plugin internals, so the gate is deliberately the
21 * site-management capability rather than anything content-level.
22 * On multisite the bar is higher still: the debug log and the
23 * PHP error log are NETWORK-wide files, so a subsite
24 * administrator must not read (or truncate) every other site's
25 * errors — the gate becomes `manage_network_options`.
26 * 2. Developer mode (`developerModeEnabled` in OpenStation
27 * Preferences, off by default). Code Blue is a developer-facing
28 * surface; until the user flips the switch, nothing registers —
29 * no icon, no window, no nav entry, no REST routes.
30 *
31 * @return bool
32 */
33 function openstation_code_blue_user_can_use() {
34 $capability = is_multisite() ? 'manage_network_options' : 'manage_options';
35 $can = current_user_can( $capability );
36
37 if ( $can && function_exists( 'openstation_get_os_settings' ) ) {
38 $settings = openstation_get_os_settings( get_current_user_id() );
39 $can = ! empty( $settings['developerModeEnabled'] );
40 }
41
42 /**
43 * Filter whether the current user can see the Code Blue desktop
44 * icon, window, and REST routes.
45 *
46 * @param bool $can Default: Developer mode enabled in
47 * OpenStation Preferences AND `manage_options`
48 * (`manage_network_options` on multisite).
49 */
50 return (bool) apply_filters( 'openstation_code_blue_user_can_use', $can );
51 }
52
53 /**
54 * How many trailing bytes of a log file to scan per request.
55 *
56 * @return int
57 */
58 function openstation_code_blue_max_bytes() {
59 /**
60 * Filter the number of trailing bytes read from a log file per
61 * request. Larger values reach further back in time at the cost
62 * of parse time and response size.
63 *
64 * @param int $max_bytes Default: 1 MiB.
65 */
66 $max = (int) apply_filters( 'openstation_code_blue_max_bytes', MB_IN_BYTES );
67 return max( 4 * KB_IN_BYTES, $max );
68 }
69
70 /**
71 * How many parsed entries a response may carry (newest kept).
72 *
73 * @return int
74 */
75 function openstation_code_blue_max_entries() {
76 /**
77 * Filter the maximum number of parsed log entries returned per
78 * request. When the scanned window holds more, the OLDEST
79 * entries are dropped.
80 *
81 * @param int $max_entries Default: 3000.
82 */
83 $max = (int) apply_filters( 'openstation_code_blue_max_entries', 3000 );
84 return max( 100, $max );
85 }
86
87 /**
88 * Discover the log files this install can offer, normalized.
89 *
90 * Built-in candidates:
91 *
92 * - `debug-log` — WP_DEBUG_LOG (string form respected; bool form
93 * resolves to wp-content/debug.log). Offered even when the
94 * constant is off if a leftover debug.log exists on disk.
95 * - `php-error-log` — the `error_log` ini directive, unless it
96 * points at syslog/stderr or at the same file as `debug-log`.
97 *
98 * Plugins append their own files via the
99 * `openstation_code_blue_log_sources` filter.
100 *
101 * @return array[] Each: `id`, `label`, `path`, `exists`,
102 * `readable`, `writable`, `size`, `mtime`.
103 */
104 function openstation_code_blue_log_sources() {
105 $sources = array();
106
107 $debug_path = '';
108 if ( defined( 'WP_DEBUG_LOG' ) ) {
109 if ( is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) {
110 $debug_path = WP_DEBUG_LOG;
111 } elseif ( WP_DEBUG_LOG ) {
112 $debug_path = WP_CONTENT_DIR . '/debug.log';
113 }
114 }
115 if ( '' === $debug_path && file_exists( WP_CONTENT_DIR . '/debug.log' ) ) {
116 $debug_path = WP_CONTENT_DIR . '/debug.log';
117 }
118 if ( '' !== $debug_path ) {
119 $sources[] = array(
120 'id' => 'debug-log',
121 'label' => __( 'WordPress debug log', 'desktop-mode' ),
122 'path' => $debug_path,
123 );
124 }
125
126 $ini_log = (string) ini_get( 'error_log' );
127 if ( '' !== $ini_log && ! in_array( $ini_log, array( 'syslog', '/dev/stderr', '/dev/stdout' ), true ) ) {
128 $same = '' !== $debug_path
129 && ( $ini_log === $debug_path
130 || ( file_exists( $ini_log ) && file_exists( $debug_path )
131 && realpath( $ini_log ) === realpath( $debug_path ) ) );
132 if ( ! $same ) {
133 $sources[] = array(
134 'id' => 'php-error-log',
135 'label' => __( 'PHP error log', 'desktop-mode' ),
136 'path' => $ini_log,
137 );
138 }
139 }
140
141 /**
142 * Filter the log sources offered by the Code Blue window.
143 *
144 * Each entry declares `id` (slug), `label`, and `path` (absolute
145 * file path). File metadata (`exists`, `readable`, `writable`,
146 * `size`, `mtime`) is derived after filtering — callers only
147 * supply the three descriptor keys.
148 *
149 * @param array[] $sources Default: WP debug log + PHP error log.
150 */
151 $sources = apply_filters( 'openstation_code_blue_log_sources', $sources );
152
153 $out = array();
154 $seen = array();
155 foreach ( (array) $sources as $source ) {
156 $id = isset( $source['id'] ) ? sanitize_key( (string) $source['id'] ) : '';
157 $path = isset( $source['path'] ) ? (string) $source['path'] : '';
158 if ( '' === $id || '' === $path || isset( $seen[ $id ] ) ) {
159 continue;
160 }
161 $seen[ $id ] = true;
162
163 $exists = is_file( $path );
164 $out[] = array(
165 'id' => $id,
166 'label' => isset( $source['label'] ) ? (string) $source['label'] : $id,
167 'path' => $path,
168 'exists' => $exists,
169 'readable' => $exists && is_readable( $path ),
170 'writable' => $exists && wp_is_writable( $path ),
171 'size' => $exists ? (int) filesize( $path ) : 0,
172 'mtime' => $exists ? (int) filemtime( $path ) : 0,
173 );
174 }
175
176 return $out;
177 }
178
179 /**
180 * Look up one normalized source descriptor by id.
181 *
182 * @param string $id Source id.
183 * @return array|null
184 */
185 function openstation_code_blue_get_source( $id ) {
186 foreach ( openstation_code_blue_log_sources() as $source ) {
187 if ( $source['id'] === $id ) {
188 return $source;
189 }
190 }
191 return null;
192 }
193
194 /**
195 * Read the trailing window of a file.
196 *
197 * When the file is larger than `$max_bytes`, seeks to the tail and
198 * drops the first (almost certainly partial) line so parsing starts
199 * on a clean record boundary.
200 *
201 * @param string $path Absolute file path.
202 * @param int $max_bytes Trailing window size.
203 * @return array `raw` (string), `truncated` (bool), `scanned_bytes` (int).
204 */
205 function openstation_code_blue_tail( $path, $max_bytes ) {
206 $result = array(
207 'raw' => '',
208 'truncated' => false,
209 'scanned_bytes' => 0,
210 );
211 if ( ! is_file( $path ) || ! is_readable( $path ) ) {
212 return $result;
213 }
214
215 $size = (int) filesize( $path );
216 if ( 0 === $size ) {
217 return $result;
218 }
219
220 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming the tail of a multi-megabyte log; WP_Filesystem has no seek-and-read.
221 $handle = fopen( $path, 'rb' );
222 if ( ! $handle ) {
223 return $result;
224 }
225
226 $offset = max( 0, $size - $max_bytes );
227 if ( $offset > 0 ) {
228 fseek( $handle, $offset );
229 $result['truncated'] = true;
230 }
231 $raw = stream_get_contents( $handle );
232 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Pairs with the fopen above.
233 fclose( $handle );
234
235 if ( false === $raw ) {
236 return $result;
237 }
238 if ( $offset > 0 ) {
239 $newline = strpos( $raw, "\n" );
240 $raw = false === $newline ? '' : substr( $raw, $newline + 1 );
241 }
242
243 $result['raw'] = $raw;
244 $result['scanned_bytes'] = strlen( $raw );
245 return $result;
246 }
247
248 /**
249 * The PHP error-label → severity map, the single source of truth
250 * for both the parse regex (built from these keys) and the label
251 * lookup — so a label added here is automatically matched.
252 *
253 * @return array<string,string> Lowercased label => severity slug.
254 */
255 function openstation_code_blue_level_map() {
256 return array(
257 'fatal error' => 'fatal',
258 'parse error' => 'fatal',
259 'core error' => 'fatal',
260 'compile error' => 'fatal',
261 'recoverable fatal error' => 'fatal',
262 'user error' => 'error',
263 'warning' => 'warning',
264 'core warning' => 'warning',
265 'compile warning' => 'warning',
266 'user warning' => 'warning',
267 'deprecated' => 'deprecated',
268 'user deprecated' => 'deprecated',
269 'notice' => 'notice',
270 'user notice' => 'notice',
271 );
272 }
273
274 /**
275 * Map a PHP error label (the text between `PHP ` and `:`) to one of
276 * the six Code Blue severities.
277 *
278 * @param string $label Error label, e.g. `Fatal error`.
279 * @return string `fatal` | `error` | `warning` | `deprecated` | `notice` | `info`.
280 */
281 function openstation_code_blue_level_for_label( $label ) {
282 $map = openstation_code_blue_level_map();
283 $label = strtolower( trim( $label ) );
284 return isset( $map[ $label ] ) ? $map[ $label ] : 'info';
285 }
286
287 /**
288 * Build the grouping signature for an entry.
289 *
290 * Two occurrences of "the same problem" must land on the same
291 * signature even when the details differ, so numbers (line numbers,
292 * ids, byte counts) and hex addresses are collapsed before hashing
293 * the message together with its level and file.
294 *
295 * @param string $level Severity slug.
296 * @param string $message Message with the location suffix removed.
297 * @param string $file Source file path, may be empty.
298 * @return string
299 */
300 function openstation_code_blue_signature( $level, $message, $file = '' ) {
301 $norm = preg_replace( '/0x[0-9a-f]+/i', 'N', $message );
302 $norm = preg_replace( '/\d+/', 'N', (string) $norm );
303 $norm = preg_replace( '/\s+/', ' ', trim( (string) $norm ) );
304 $norm = substr( (string) $norm, 0, 240 );
305 return $level . '|' . $norm . '|' . $file;
306 }
307
308 /**
309 * Parse raw log text into structured entries.
310 *
311 * Understands the formats a WordPress install actually produces:
312 *
313 * - `[d-M-Y H:i:s TZ] PHP <label>: message in /file on line N`
314 * - `[d-M-Y H:i:s TZ] PHP <label>: message in /file:N`
315 * - `[d-M-Y H:i:s TZ] WordPress database error <err> for query …`
316 * - Untimestamped trace-shaped lines (`Stack trace:`, `#0 …`,
317 * `thrown in …`, indented text) attach to the preceding
318 * entry's trace.
319 * - Timestamped Xdebug frames (`PHP Stack trace:`, `PHP 1. …`)
320 * attach to the preceding entry's trace.
321 * - Anything else — timestamped or not — becomes an `info`
322 * entry, so custom `error_log()` calls from plugins (including
323 * type-3 writes to a plugin's own file, which carry no
324 * timestamp prefix) stay visible as individual entries.
325 *
326 * @param string $raw Raw log text.
327 * @return array[] Entries: `timestamp` (int|null UTC), `level`,
328 * `label`, `message`, `file`, `line`, `trace`,
329 * `signature` — in file order (oldest first).
330 */
331 function openstation_code_blue_parse( $raw ) {
332 $entries = array();
333 $current = null;
334
335 $lines = preg_split( '/\r\n|\n|\r/', (string) $raw );
336 foreach ( $lines as $line ) {
337 if ( '' === trim( $line ) ) {
338 continue;
339 }
340
341 // The timezone class includes ':' for offset-form values
342 // (`+02:00`), which PHP's `T` emits for offset-only
343 // `date.timezone` settings.
344 if ( ! preg_match( '/^\[(\d{1,2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}(?:\s+[A-Za-z0-9_\/+:\-]+)?)\]\s?(.*)$/', $line, $m ) ) {
345 // Untimestamped line. Only trace-shaped lines (stack
346 // frames, `thrown in`, indented continuations) attach to
347 // the previous entry — anything else is its own record,
348 // so a plugin log written with `error_log( $msg, 3, … )`
349 // (no timestamp prefix) stays one entry per line instead
350 // of collapsing into the first line's trace.
351 $is_trace_shape = (bool) preg_match( '/^(Stack trace:|#\d+|thrown in\b|\s)/', $line );
352 if ( null !== $current && $is_trace_shape ) {
353 $current['trace'] .= ( '' === $current['trace'] ? '' : "\n" ) . rtrim( $line );
354 continue;
355 }
356 if ( null !== $current ) {
357 $entries[] = $current;
358 }
359 $current = openstation_code_blue_make_entry( null, 'info', __( 'Log', 'desktop-mode' ), trim( $line ) );
360 continue;
361 }
362
363 $timestamp = openstation_code_blue_parse_timestamp( $m[1] );
364 $rest = $m[2];
365
366 // Xdebug trace lines are timestamped but belong to the
367 // preceding error, not to a record of their own.
368 if ( null !== $current && preg_match( '/^PHP (Stack trace:|\s*\d+\.\s)/', $rest ) ) {
369 $current['trace'] .= ( '' === $current['trace'] ? '' : "\n" ) . rtrim( $rest );
370 continue;
371 }
372
373 if ( null !== $current ) {
374 $entries[] = $current;
375 $current = null;
376 }
377
378 $labels_re = implode( '|', array_map( 'preg_quote', array_keys( openstation_code_blue_level_map() ) ) );
379 if ( preg_match( '/^PHP (' . $labels_re . ')\s*:\s*(.*)$/i', $rest, $em ) ) {
380 $current = openstation_code_blue_make_entry(
381 $timestamp,
382 openstation_code_blue_level_for_label( $em[1] ),
383 'PHP ' . $em[1],
384 $em[2]
385 );
386 continue;
387 }
388
389 if ( preg_match( '/^WordPress database error\s+(.*)$/', $rest, $dm ) ) {
390 $message = $dm[1];
391 $trace = '';
392 $split = strpos( $message, ' for query ' );
393 if ( false !== $split ) {
394 $trace = 'Query: ' . substr( $message, $split + strlen( ' for query ' ) );
395 $message = substr( $message, 0, $split );
396 $made_by = strpos( $trace, ' made by ' );
397 if ( false !== $made_by ) {
398 $trace = substr( $trace, 0, $made_by ) . "\nMade by: " . substr( $trace, $made_by + strlen( ' made by ' ) );
399 }
400 }
401 $current = openstation_code_blue_make_entry( $timestamp, 'error', __( 'Database error', 'desktop-mode' ), $message );
402 $current['trace'] = $trace;
403 continue;
404 }
405
406 $current = openstation_code_blue_make_entry( $timestamp, 'info', __( 'Log', 'desktop-mode' ), $rest );
407 }
408
409 if ( null !== $current ) {
410 $entries[] = $current;
411 }
412
413 return $entries;
414 }
415
416 /**
417 * Build one entry: extract the `in /file on line N` suffix, then
418 * derive the grouping signature.
419 *
420 * @param int|null $timestamp Unix timestamp (UTC) or null.
421 * @param string $level Severity slug.
422 * @param string $label Human label, e.g. `PHP Fatal error`.
423 * @param string $message Message text (location suffix still attached).
424 * @return array
425 */
426 function openstation_code_blue_make_entry( $timestamp, $level, $label, $message ) {
427 // `_doing_it_wrong()` and friends log HTML (`<strong>`, `<code>`)
428 // — strip it so the UI shows prose, not markup. Deliberately NOT
429 // `wp_strip_all_tags()`: that treats a bare `<` (as in a parse
430 // error's `unexpected '<'`) as an unterminated tag and deletes
431 // the rest of the message, location suffix included. This only
432 // removes well-formed tags.
433 $message = trim( (string) preg_replace( '/<\/?[a-zA-Z][^<>]*>/', '', $message ) );
434 $file = '';
435 $line = 0;
436
437 if ( preg_match( '/^(.*?)\s+in\s+(\S+?)(?::(\d+)|\s+on\s+line\s+(\d+))$/s', $message, $m ) ) {
438 $message = trim( $m[1] );
439 $file = $m[2];
440 $line = (int) ( '' !== $m[3] ? $m[3] : $m[4] );
441 }
442
443 return array(
444 'timestamp' => $timestamp,
445 'level' => $level,
446 'label' => $label,
447 'message' => $message,
448 'file' => $file,
449 'line' => $line,
450 'trace' => '',
451 'signature' => openstation_code_blue_signature( $level, $message, $file ),
452 );
453 }
454
455 /**
456 * Parse a log timestamp like `22-Aug-2026 09:14:02 UTC`.
457 *
458 * @param string $raw Timestamp text between the brackets.
459 * @return int|null Unix timestamp, or null when unparseable.
460 */
461 function openstation_code_blue_parse_timestamp( $raw ) {
462 $raw = trim( $raw );
463
464 $date = DateTime::createFromFormat( 'd-M-Y H:i:s T', $raw );
465 if ( false === $date ) {
466 $date = DateTime::createFromFormat( 'd-M-Y H:i:s', $raw, new DateTimeZone( 'UTC' ) );
467 }
468 if ( false === $date ) {
469 $fallback = strtotime( $raw );
470 return false === $fallback ? null : $fallback;
471 }
472 return $date->getTimestamp();
473 }
474
475 /**
476 * Read + parse one source, applying the byte and entry caps.
477 *
478 * @param array $source Normalized descriptor from
479 * {@see openstation_code_blue_log_sources()}.
480 * @return array `entries`, `truncated`, `scanned_bytes`, `dropped_entries`.
481 */
482 function openstation_code_blue_read_source( $source ) {
483 $tail = openstation_code_blue_tail( $source['path'], openstation_code_blue_max_bytes() );
484 $entries = openstation_code_blue_parse( $tail['raw'] );
485
486 /**
487 * Filter the parsed entries for one log source.
488 *
489 * The escape hatch for logs the built-in parser doesn't
490 * understand (Monolog, ISO-timestamped formats, …): a plugin
491 * that registered a source via `openstation_code_blue_log_sources`
492 * can re-parse `$raw` itself here and return its own entry
493 * array. Each entry: `timestamp` (int|null), `level`, `label`,
494 * `message`, `file`, `line`, `trace`, `signature`.
495 *
496 * @param array[] $entries Parsed entries, oldest first.
497 * @param array $source Normalized source descriptor.
498 * @param string $raw The raw scanned tail the entries came from.
499 */
500 $entries = (array) apply_filters( 'openstation_code_blue_entries', $entries, $source, $tail['raw'] );
501
502 $max = openstation_code_blue_max_entries();
503 $dropped = 0;
504 if ( count( $entries ) > $max ) {
505 $dropped = count( $entries ) - $max;
506 $entries = array_slice( $entries, -$max );
507 }
508
509 return array(
510 'entries' => $entries,
511 'truncated' => $tail['truncated'] || $dropped > 0,
512 'scanned_bytes' => $tail['scanned_bytes'],
513 'dropped_entries' => $dropped,
514 );
515 }
516