PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.8
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 0.8.6 All 33 releases
desktop-mode / apps / code-blue / log-reader.php

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

547 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Code Blue — the log model.
4 *
5 * Discovery, tailing, parsing and clearing of the logs an install can
6 * produce — the server half. Grouping, filtering and time buckets
7 * run in the browser (`code-blue.os.ts`) so every filter is instant.
8 * Everything here is a plain namespaced function that talks to the
9 * host only through `$os`, so the same code runs on WordPress and on
10 * a bare PHP host.
11 *
12 * @package OpenStation
13 */
14
15 namespace OpenStation\Apps\CodeBlue;
16
17 use OpenStation\App\Os;
18
19 // Direct access, unless a standalone host is booting on bare PHP.
20 if ( ! defined( 'ABSPATH' ) ) {
21 defined( 'OPENSTATION_STANDALONE' ) || exit;
22 }
23
24 const MAX_BYTES = 1048576;
25 const MAX_ENTRIES = 3000;
26
27 /**
28 * Whether the acting user may use Code Blue: site management (network
29 * management on a network — the logs are shared files) AND Developer
30 * mode in OpenStation Preferences.
31 *
32 * @param Os $os Host handle.
33 * @return bool
34 */
35 function can_use( Os $os ) {
36 $can = $os->can( $os->env->is_network() ? 'manage_network_options' : 'manage_options' )
37 && ! empty( $os->preference( 'developerModeEnabled' ) );
38
39 /**
40 * Filter whether the current user can see the Code Blue window.
41 *
42 * @param bool $can Default: Developer mode on AND `manage_options`
43 * (`manage_network_options` on a network).
44 */
45 return (bool) $os->filter( 'openstation_code_blue_user_can_use', $can );
46 }
47
48 /**
49 * The PHP error-label → severity map; the single source of truth for
50 * both the parse regex and the label lookup.
51 *
52 * @return array<string,string>
53 */
54 function level_map() {
55 return array(
56 'fatal error' => 'fatal',
57 'parse error' => 'fatal',
58 'core error' => 'fatal',
59 'compile error' => 'fatal',
60 'recoverable fatal error' => 'fatal',
61 'user error' => 'error',
62 'warning' => 'warning',
63 'core warning' => 'warning',
64 'compile warning' => 'warning',
65 'user warning' => 'warning',
66 'deprecated' => 'deprecated',
67 'user deprecated' => 'deprecated',
68 'notice' => 'notice',
69 'user notice' => 'notice',
70 );
71 }
72
73 /**
74 * Grouping key: numbers and hex addresses collapse so two occurrences
75 * of the same problem land together.
76 *
77 * @param string $level Severity slug.
78 * @param string $message Message without the location suffix.
79 * @param string $file File path, may be ''.
80 * @return string
81 */
82 function signature( $level, $message, $file = '' ) {
83 $norm = preg_replace( '/0x[0-9a-f]+/i', 'N', (string) $message );
84 $norm = preg_replace( '/\d+/', 'N', (string) $norm );
85 $norm = preg_replace( '/\s+/', ' ', trim( (string) $norm ) );
86 return $level . '|' . substr( (string) $norm, 0, 240 ) . '|' . $file;
87 }
88
89 /**
90 * Build one entry: strip well-formed HTML (`_doing_it_wrong()` logs
91 * markup; a bare `<` from a parse error must survive), pull the
92 * `in /file on line N` suffix out, derive the signature.
93 *
94 * @param int|null $timestamp Unix seconds or null.
95 * @param string $level Severity slug.
96 * @param string $label Human label, e.g. `PHP Fatal error`.
97 * @param string $message Message text.
98 * @return array<string,mixed>
99 */
100 function make_entry( $timestamp, $level, $label, $message ) {
101 $message = trim( (string) preg_replace( '/<\/?[a-zA-Z][^<>]*>/', '', (string) $message ) );
102 $file = '';
103 $line = 0;
104 if ( preg_match( '/^(.*?)\s+in\s+(\S+?)(?::(\d+)|\s+on\s+line\s+(\d+))$/s', $message, $m ) ) {
105 $message = trim( $m[1] );
106 $file = $m[2];
107 $line = (int) ( '' !== $m[3] ? $m[3] : $m[4] );
108 }
109 return array(
110 'timestamp' => $timestamp,
111 'level' => $level,
112 'label' => $label,
113 'message' => $message,
114 'file' => $file,
115 'line' => $line,
116 'trace' => '',
117 'signature' => signature( $level, $message, $file ),
118 );
119 }
120
121 /**
122 * Whose code is this? The first question anyone asks of a log line,
123 * and the last one the log itself answers — the path is right there in
124 * every entry, and reading `wp-content/plugins/<slug>/` off it turns
125 * "some fatal error" into "WooCommerce's fatal error", which is the
126 * difference between triage and archaeology.
127 *
128 * Deliberately conservative: a path that is not clearly under the
129 * content directory or clearly inside core answers `unknown` rather
130 * than guessing, because a wrong attribution sends someone into the
131 * wrong codebase. Single-file plugins and mu-plugins keep their
132 * basename as the slug; there is no directory to name them by.
133 *
134 * This is the ONLY implementation. The window renders what it returns
135 * and the debugging abilities report it verbatim — a second copy of
136 * this classification would let the two disagree about whose bug it is.
137 *
138 * @param string $file Absolute path from a log entry, may be ''.
139 * @param string $content_dir The install's `wp-content` equivalent.
140 * @return array{kind:string,slug:string} `kind` is one of `plugin`,
141 * `mu-plugin`, `theme`, `core`, `unknown`.
142 */
143 function origin( $file, $content_dir ) {
144 $path = str_replace( '\\', '/', (string) $file );
145 $content = rtrim( str_replace( '\\', '/', (string) $content_dir ), '/' );
146 if ( '' !== $content && 0 === strpos( $path, $content . '/' ) ) {
147 $rest = substr( $path, strlen( $content ) + 1 );
148 if ( preg_match( '#^(plugins|mu-plugins|themes)/([^/]+)#', $rest, $m ) ) {
149 $kinds = array(
150 'plugins' => 'plugin',
151 'mu-plugins' => 'mu-plugin',
152 'themes' => 'theme',
153 );
154 return array(
155 'kind' => $kinds[ $m[1] ],
156 'slug' => preg_replace( '/\.php$/', '', $m[2] ),
157 );
158 }
159 }
160 if ( preg_match( '#/wp-(admin|includes)/#', $path ) ) {
161 return array(
162 'kind' => 'core',
163 'slug' => '',
164 );
165 }
166 return array(
167 'kind' => 'unknown',
168 'slug' => '',
169 );
170 }
171
172 /**
173 * Parse `22-Aug-2026 09:14:02 UTC` (or the same without a zone,
174 * read as UTC).
175 *
176 * @param string $raw Text between the brackets.
177 * @return int|null
178 */
179 function parse_timestamp( $raw ) {
180 $raw = trim( (string) $raw );
181 $date = \DateTime::createFromFormat( 'd-M-Y H:i:s T', $raw );
182 if ( false === $date ) {
183 $date = \DateTime::createFromFormat( 'd-M-Y H:i:s', $raw, new \DateTimeZone( 'UTC' ) );
184 }
185 if ( false === $date ) {
186 $fallback = strtotime( $raw );
187 return false === $fallback ? null : $fallback;
188 }
189 return $date->getTimestamp();
190 }
191
192 /**
193 * Parse raw log text into entries, oldest first.
194 *
195 * Understands `[stamp] PHP <label>: message in /file on line N` (and
196 * the `:N` form), `[stamp] WordPress database error … for query …`,
197 * Xdebug frames, untimestamped trace lines (attached to the previous
198 * entry), and treats anything else as an `info` entry of its own.
199 *
200 * @param string $raw Raw log text.
201 * @return array[]
202 */
203 function parse( $raw ) {
204 $entries = array();
205 $current = null;
206 $labels_re = implode( '|', array_map( 'preg_quote', array_keys( level_map() ) ) );
207 $log_label = __( 'Log', 'desktop-mode' );
208
209 foreach ( preg_split( '/\r\n|\n|\r/', (string) $raw ) as $line ) {
210 if ( '' === trim( $line ) ) {
211 continue;
212 }
213 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 ) ) {
214 if ( null !== $current && preg_match( '/^(Stack trace:|#\d+|thrown in\b|\s)/', $line ) ) {
215 $current['trace'] .= ( '' === $current['trace'] ? '' : "\n" ) . rtrim( $line );
216 continue;
217 }
218 if ( null !== $current ) {
219 $entries[] = $current;
220 }
221 $current = make_entry( null, 'info', $log_label, trim( $line ) );
222 continue;
223 }
224
225 $timestamp = parse_timestamp( $m[1] );
226 $rest = $m[2];
227 if ( null !== $current && preg_match( '/^PHP (Stack trace:|\s*\d+\.\s)/', $rest ) ) {
228 $current['trace'] .= ( '' === $current['trace'] ? '' : "\n" ) . rtrim( $rest );
229 continue;
230 }
231 if ( null !== $current ) {
232 $entries[] = $current;
233 }
234
235 if ( preg_match( '/^PHP (' . $labels_re . ')\s*:\s*(.*)$/i', $rest, $em ) ) {
236 $map = level_map();
237 $current = make_entry( $timestamp, $map[ strtolower( trim( $em[1] ) ) ], 'PHP ' . $em[1], $em[2] );
238 continue;
239 }
240 if ( preg_match( '/^WordPress database error\s+(.*)$/', $rest, $dm ) ) {
241 $message = $dm[1];
242 $trace = '';
243 $split = strpos( $message, ' for query ' );
244 if ( false !== $split ) {
245 $trace = 'Query: ' . substr( $message, $split + 11 );
246 $message = substr( $message, 0, $split );
247 $made_by = strpos( $trace, ' made by ' );
248 if ( false !== $made_by ) {
249 $trace = substr( $trace, 0, $made_by ) . "\nMade by: " . substr( $trace, $made_by + 9 );
250 }
251 }
252 $current = make_entry( $timestamp, 'error', __( 'Database error', 'desktop-mode' ), $message );
253 $current['trace'] = $trace;
254 continue;
255 }
256 $current = make_entry( $timestamp, 'info', $log_label, $rest );
257 }
258 if ( null !== $current ) {
259 $entries[] = $current;
260 }
261 return $entries;
262 }
263
264 /**
265 * Read the trailing window of a file, dropping the first (partial)
266 * line when the file was longer than the window.
267 *
268 * @param string $path Absolute path.
269 * @param int $max_bytes Window size.
270 * @return array{raw:string,truncated:bool,scanned_bytes:int}
271 */
272 function tail( $path, $max_bytes ) {
273 $result = array(
274 'raw' => '',
275 'truncated' => false,
276 'scanned_bytes' => 0,
277 );
278 if ( ! is_file( $path ) || ! is_readable( $path ) ) {
279 return $result;
280 }
281 $size = (int) filesize( $path );
282 if ( 0 === $size ) {
283 return $result;
284 }
285 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming the tail of a multi-megabyte log; WP_Filesystem has no seek-and-read.
286 $handle = fopen( $path, 'rb' );
287 if ( ! $handle ) {
288 return $result;
289 }
290 $offset = max( 0, $size - $max_bytes );
291 if ( $offset > 0 ) {
292 fseek( $handle, $offset );
293 $result['truncated'] = true;
294 }
295 $raw = stream_get_contents( $handle );
296 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Pairs with the fopen above.
297 fclose( $handle );
298 if ( false === $raw ) {
299 return $result;
300 }
301 if ( $offset > 0 ) {
302 $newline = strpos( $raw, "\n" );
303 $raw = false === $newline ? '' : substr( $raw, $newline + 1 );
304 }
305 $result['raw'] = $raw;
306 $result['scanned_bytes'] = strlen( $raw );
307 return $result;
308 }
309
310 /**
311 * The log files this install offers, normalised with file metadata.
312 *
313 * @param Os $os Host handle.
314 * @return array[] Each: `id`, `label`, `path`, `exists`, `readable`, `writable`, `size`, `mtime`.
315 */
316 function sources( Os $os ) {
317 $sources = array();
318 $debug_log = $os->env->constant( 'WP_DEBUG_LOG', false );
319 $debug_path = '';
320 if ( is_string( $debug_log ) && '' !== $debug_log ) {
321 $debug_path = $debug_log;
322 } elseif ( $debug_log || file_exists( $os->env->content_dir() . '/debug.log' ) ) {
323 $debug_path = $os->env->content_dir() . '/debug.log';
324 }
325 if ( '' !== $debug_path ) {
326 $sources[] = array(
327 'id' => 'debug-log',
328 'label' => __( 'WordPress debug log', 'desktop-mode' ),
329 'path' => $debug_path,
330 );
331 }
332
333 $ini_log = (string) ini_get( 'error_log' );
334 if ( '' !== $ini_log && ! in_array( $ini_log, array( 'syslog', '/dev/stderr', '/dev/stdout' ), true ) ) {
335 $same = '' !== $debug_path && ( $ini_log === $debug_path
336 || ( file_exists( $ini_log ) && file_exists( $debug_path ) && realpath( $ini_log ) === realpath( $debug_path ) ) );
337 if ( ! $same ) {
338 $sources[] = array(
339 'id' => 'php-error-log',
340 'label' => __( 'PHP error log', 'desktop-mode' ),
341 'path' => $ini_log,
342 );
343 }
344 }
345
346 /**
347 * Filter the log sources Code Blue offers. Each: `id`, `label`,
348 * `path` — metadata is derived after filtering.
349 *
350 * @param array[] $sources Default: WP debug log + PHP error log.
351 */
352 $sources = $os->filter( 'openstation_code_blue_log_sources', $sources );
353
354 $out = array();
355 $seen = array();
356 foreach ( (array) $sources as $source ) {
357 $id = isset( $source['id'] ) ? strtolower( (string) preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $source['id'] ) ) : '';
358 $path = isset( $source['path'] ) ? (string) $source['path'] : '';
359 if ( '' === $id || '' === $path || isset( $seen[ $id ] ) ) {
360 continue;
361 }
362 $seen[ $id ] = true;
363 $exists = is_file( $path );
364 $out[] = array(
365 'id' => $id,
366 'label' => isset( $source['label'] ) ? (string) $source['label'] : $id,
367 'path' => $path,
368 'exists' => $exists,
369 'readable' => $exists && is_readable( $path ),
370 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Probing a server-side log path from the host-agnostic model; `wp_is_writable()` does not exist on a standalone host.
371 'writable' => $exists && is_writable( $path ),
372 'size' => $exists ? (int) filesize( $path ) : 0,
373 'mtime' => $exists ? (int) filemtime( $path ) : 0,
374 );
375 }
376 return $out;
377 }
378
379 /**
380 * Whether a source can be read as a log: a missing file is an EMPTY
381 * log; only exists-but-unreadable is dead.
382 *
383 * @param array<string,mixed> $source Normalised source.
384 * @return bool
385 */
386 function usable( array $source ) {
387 return $source['readable'] || ! $source['exists'];
388 }
389
390 /**
391 * Read + parse one source.
392 *
393 * Deliberately uncached. A log reader's product is freshness, and an
394 * object cache would have to be keyed on more than the file: the
395 * `entries` / `max_bytes` / `max_entries` filters all shape the result,
396 * and `parse()` bakes localized level labels into it — on a Redis or
397 * Memcached install a filter change would lag and two admins in
398 * different locales would read each other's labels. A bounded tail
399 * (1 MB by default) parsed on an explicit Refresh is cheap enough that
400 * none of that is worth buying.
401 *
402 * @param Os $os Host handle.
403 * @param array<string,mixed> $source Normalised source.
404 * @return array{entries:array[],truncated:bool,scanned_bytes:int,dropped:int,error:string}
405 */
406 function read( Os $os, array $source ) {
407 $empty = array(
408 'entries' => array(),
409 'truncated' => false,
410 'scanned_bytes' => 0,
411 'dropped' => 0,
412 'error' => '',
413 );
414 if ( ! $source['exists'] ) {
415 return $empty;
416 }
417 if ( ! $source['readable'] ) {
418 $empty['error'] = __( 'The log file exists but PHP cannot read it.', 'desktop-mode' );
419 return $empty;
420 }
421
422 $max_bytes = max( 4096, (int) $os->filter( 'openstation_code_blue_max_bytes', MAX_BYTES ) );
423 $max_entries = max( 100, (int) $os->filter( 'openstation_code_blue_max_entries', MAX_ENTRIES ) );
424 $tail = tail( $source['path'], $max_bytes );
425 /**
426 * Filter the parsed entries for one source — re-parse `$raw`
427 * yourself for a format the built-in parser doesn't know.
428 *
429 * @param array[] $entries Parsed entries, oldest first.
430 * @param array $source Normalised source.
431 * @param string $raw The scanned tail.
432 */
433 $entries = (array) $os->filter( 'openstation_code_blue_entries', parse( $tail['raw'] ), $source, $tail['raw'] );
434
435 // Attribution runs AFTER the filter so entries a plugin's own parser
436 // contributed are attributed too — `parse()` never sees them.
437 $content_dir = $os->env->content_dir();
438 foreach ( $entries as $index => $entry ) {
439 $entries[ $index ]['origin'] = origin( isset( $entry['file'] ) ? $entry['file'] : '', $content_dir );
440 }
441
442 $dropped = max( 0, count( $entries ) - $max_entries );
443 if ( $dropped > 0 ) {
444 $entries = array_slice( $entries, -$max_entries );
445 }
446 return array_merge(
447 $empty,
448 array(
449 'entries' => $entries,
450 'truncated' => $tail['truncated'] || $dropped > 0,
451 'scanned_bytes' => $tail['scanned_bytes'],
452 'dropped' => $dropped,
453 )
454 );
455 }
456
457 /**
458 * Truncate a source's file.
459 *
460 * @param Os $os Host handle.
461 * @param array<string,mixed> $source Normalised source.
462 * @return true|string `true`, or an error message.
463 */
464 function clear( Os $os, array $source ) {
465 if ( ! $source['exists'] ) {
466 return true;
467 }
468 if ( ! $source['writable'] ) {
469 return __( 'The log file is not writable, so it cannot be cleared.', 'desktop-mode' );
470 }
471 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Truncating a server-side log the descriptor already validated.
472 if ( false === file_put_contents( $source['path'], '' ) ) {
473 return __( 'Clearing the log file failed.', 'desktop-mode' );
474 }
475 /**
476 * Fires after Code Blue truncates a log file.
477 *
478 * @param string $id Source id.
479 * @param string $path Absolute path.
480 */
481 $os->action( 'openstation_code_blue_log_cleared', $source['id'], $source['path'] );
482 return true;
483 }
484
485 /**
486 * The URL template the "Search the web" link on an issue resolves
487 * against — `%s` is the URL-encoded message. Looking an unfamiliar
488 * error up is the next thing anyone does after reading it, and where
489 * they look is a house style: an agency may want its own wiki, a
490 * hosting platform its own knowledge base.
491 *
492 * Return an empty string to drop the link entirely.
493 *
494 * @param Os $os Host handle.
495 * @return string URL template containing `%s`, or '' for no link.
496 */
497 function search_url( Os $os ) {
498 /**
499 * Filter the search URL template for a log message.
500 *
501 * @param string $template `%s` is replaced with the URL-encoded
502 * message. Empty string hides the link.
503 */
504 return (string) $os->filter( 'openstation_code_blue_search_url', 'https://duckduckgo.com/?q=%s' );
505 }
506
507 /**
508 * The environment card: debug switches and versions.
509 *
510 * @param Os $os Host handle.
511 * @return array[] Each: `label`, `value`, `on` (bool|null).
512 */
513 function environment( Os $os ) {
514 $rows = array();
515 foreach ( array( 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES' ) as $constant ) {
516 $on = (bool) $os->env->constant( $constant, false );
517 $rows[] = array(
518 'label' => $constant,
519 'value' => $on ? 'on' : 'off',
520 'on' => $on,
521 );
522 }
523 $platform = $os->env->platform();
524 $rows[] = array(
525 'label' => __( 'Environment', 'desktop-mode' ),
526 'value' => $os->env->environment_type(),
527 'on' => null,
528 );
529 $rows[] = array(
530 'label' => 'PHP',
531 'value' => PHP_VERSION,
532 'on' => null,
533 );
534 $rows[] = array(
535 'label' => $platform['name'],
536 'value' => $platform['version'],
537 'on' => null,
538 );
539
540 /**
541 * Filter the environment rows shown in the Code Blue window.
542 *
543 * @param array[] $rows Each: `label`, `value`, `on` (bool|null).
544 */
545 return (array) $os->filter( 'openstation_code_blue_environment', $rows );
546 }
547