false, 'path' => '', 'readable' => false, 'reason' => '', ); if ( ! DLOGR_Boot::is_pinned( 'error_log' ) ) { return $info; } $info['pinned'] = true; $path = (string) ini_get( 'error_log' ); /* * Empty sends entries to the error output of the web server, and * "syslog" to the system logger. Neither is a file PHP can name. */ if ( '' === $path || 'syslog' === $path ) { $info['reason'] = 'unknown'; return $info; } $info['path'] = $path; if ( self::outside_basedir( $path ) ) { $info['reason'] = 'basedir'; } elseif ( ! is_file( $path ) ) { $info['reason'] = 'missing'; } elseif ( ! is_readable( $path ) ) { $info['reason'] = 'denied'; } else { $info['readable'] = true; } return $info; } /** * Path of the server's fixed log, when the plugin can read it. * * @return string Empty string when it is not fixed or cannot be read. */ public static function server_path() { $info = self::server_log(); return $info['readable'] ? $info['path'] : ''; } /** * Whether the server's fixed log can be offered for reading. * * @return bool */ public static function has_server() { return '' !== self::server_path(); } /** * Reads one helping out of the server's fixed log. * * The third door into the same machinery. The file is PHP's own log in * PHP's own format, so the reader needs nothing new for it; only the whole * file is one stream, with every request type mixed in. * * @param int|null $before Byte offset to read backwards from; null for the end of the file. * @param int $limit How many records to return. * @return array See read_file(). */ public static function read_server( $before = null, $limit = self::PAGE_SIZE ) { return self::read_file( self::server_path(), $before, $limit ); } /** * Whether open_basedir keeps a path out of reach. * * The same prefix test PHP makes. Done by hand because asking PHP itself - * is_file() on such a path - answers false with a warning, which says * nothing about why. * * @param string $path Absolute path. * @return bool */ private static function outside_basedir( $path ) { $basedir = (string) ini_get( 'open_basedir' ); if ( '' === $basedir ) { return false; } $path = wp_normalize_path( $path ); foreach ( explode( PATH_SEPARATOR, $basedir ) as $allowed ) { $allowed = wp_normalize_path( trim( $allowed ) ); if ( '' !== $allowed && 0 === strpos( $path, $allowed ) ) { return false; } } return true; } /** * Reads one helping of records, newest first in the file, oldest last. * * The cursor is a byte offset: the start of the oldest record already * shown. Offsets stay valid while the file only grows, because a log is * append-only - PHP opens it, appends and closes on every call, so bytes * already written never move. Anything appended while somebody reads sits * above the cursor and simply waits for the next page load. * * ⚠️ Shortening is the case that breaks a cursor: rotation, truncation, * deletion. A cursor past the end of the file is not an error to report but * a page to redraw, so the reader says 'reset' and starts from the end * again. * * The size is taken once, before reading, and everything appended during * the walk is ignored: without that the output would creep on a busy site. * * ⚠️ A single record larger than the byte fuse is kept rather than dropped, * even though its front is missing. Dropping it would mean answering with * nothing at all and never advancing the cursor. * * @param string $path Absolute path, already decided by the caller. * @param int|null $before Byte offset to read backwards from; null for the end of the file. * @param int $limit How many records to return. * @return array Keys: exists, readable, size, reset, fused, records, next. */ private static function read_file( $path, $before = null, $limit = self::PAGE_SIZE ) { $result = array( 'exists' => false, 'readable' => false, 'size' => 0, 'reset' => false, 'fused' => false, 'records' => array(), 'next' => null, ); if ( '' === $path || ! is_file( $path ) ) { return $result; } $result['exists'] = true; $size = filesize( $path ); if ( false === $size ) { return $result; } $result['size'] = (int) $size; if ( null !== $before && ( $before < 0 || $before > $size ) ) { $before = null; $result['reset'] = true; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Read directly: see the class docblock. $handle = fopen( $path, 'rb' ); if ( false === $handle ) { return $result; } $result['readable'] = true; $end = null === $before ? (int) $size : (int) $before; $limit = max( 1, (int) $limit ); $buffer = ''; $start = $end; $taken = 0; while ( $start > 0 && $taken < self::BYTE_LIMIT ) { $chunk = (int) min( self::BLOCK_SIZE, $start, self::BYTE_LIMIT - $taken ); $start -= $chunk; if ( -1 === fseek( $handle, $start ) ) { break; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Read directly: see the class docblock. $piece = fread( $handle, $chunk ); if ( false === $piece || '' === $piece ) { break; } $buffer = $piece . $buffer; $taken += $chunk; /* * One more than asked for: the oldest record in the buffer is a * fragment whenever the walk stopped short of the start of the * file, and it gets dropped below. */ if ( preg_match_all( '/^' . self::STAMP . '/m', $buffer ) > $limit ) { break; } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Read directly: see the class docblock. fclose( $handle ); /* * The walk ran into the fuse with bytes still ahead of it. Asking for * more records would read the same bytes and stop in the same place, so * the page has to say that rather than offer a link that does nothing. */ $result['fused'] = ( $start > 0 && $taken >= self::BYTE_LIMIT ); $records = self::parse( $buffer, $start ); if ( $start > 0 && count( $records ) > 1 ) { array_shift( $records ); } if ( count( $records ) > $limit ) { $records = array_slice( $records, -$limit ); } $result['records'] = $records; if ( ! empty( $records ) ) { $oldest = $records[0]['offset']; $result['next'] = $oldest > 0 ? $oldest : null; } return $result; } /** * Splits a buffer into records, in file order. * * A line carrying a timestamp opens a record; everything else continues the * one above it. Lines standing before the first timestamp form an orphan * record rather than joining the record below them: they belong to whatever * came earlier in the file, not to what follows. * * The timestamp and the type prefix are taken out of the text and returned * on their own, because the page shows each of them in its own column and * printing them twice reads as a bug. Nothing else is edited: the message, * the paths and the stack trace stay exactly as PHP wrote them. * * @param string $buffer Bytes read from the file. * @param int $offset Absolute offset the buffer starts at. * @return array List of arrays with keys offset, time, type, text. */ private static function parse( $buffer, $offset ) { $records = array(); if ( '' === $buffer ) { return $records; } $lines = explode( "\n", $buffer ); $last = count( $lines ) - 1; if ( '' === $lines[ $last ] ) { array_pop( $lines ); } $position = (int) $offset; foreach ( $lines as $line ) { $length = strlen( $line ) + 1; $clean = rtrim( $line, "\r" ); if ( preg_match( '/^' . self::STAMP . '\s?/', $clean, $found ) ) { list( $type, $text ) = self::split_type( substr( $clean, strlen( $found[0] ) ) ); $records[] = array( 'offset' => $position, 'time' => $found[1], 'type' => $type, 'text' => $text, ); } elseif ( empty( $records ) ) { $records[] = array( 'offset' => $position, 'time' => '', 'type' => '', 'text' => $clean, ); } else { $records[ count( $records ) - 1 ]['text'] .= "\n" . $clean; } $position += $length; } return $records; } /** * Splits the type PHP put at the front of a record off from the message. * * Costs one match on a string that is already in memory. The prefix leaves * the text because the type has a column of its own, and a record reading * "PHP Warning: PHP Warning: ..." across two columns looks like a defect * in the plugin rather than in the site. * * Records written by a direct error_log() call carry no type at all: they * come back with an empty type and an untouched text, and the page prints * a dash rather than an empty cell. * * @param string $text Record text with the timestamp already removed. * @return array Type first, message second; the type is an empty string when there is none. */ private static function split_type( $text ) { if ( preg_match( '/^(PHP [A-Za-z]+(?: [a-z]+){0,3})\s*:\s*/', $text, $found ) ) { return array( $found[1], substr( $text, strlen( $found[0] ) ) ); } return array( '', $text ); } }