*/ public static function status(): array { if ( class_exists( \GripMCP\Premium\Paid_Debug_Logging::class ) ) { return \GripMCP\Premium\Paid_Debug_Logging::status(); } return array( 'site_debug_log' => self::site_log_enabled(), 'crashes_recorded' => count( self::crashes() ), ); } /** * Whether the site already logs through WP_DEBUG_LOG in wp-config.php. */ public static function site_log_enabled(): bool { return defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG; } /** * Absolute path of the paid version's temporary log, or '' when there * is none. */ public static function log_file(): string { $state = self::state(); return self::valid_file( $state['file'], $state['dir'] ) && is_file( $state['file'] ) ? $state['file'] : ''; } /** * Recorded crashes, oldest first. * * @return array> */ public static function crashes(): array { $crashes = get_option( self::CRASHES_OPTION, array() ); return is_array( $crashes ) ? array_values( array_filter( $crashes, 'is_array' ) ) : array(); } /** * Filter callback for `wp_php_error_message`: record, then pass through. * * @param mixed $message Error page message. * @param mixed $error error_get_last() array. * @return mixed */ public static function on_wp_fatal( $message, $error ) { if ( is_array( $error ) ) { if ( class_exists( \GripMCP\Premium\Paid_Debug_Logging::class ) ) { \GripMCP\Premium\Paid_Debug_Logging::log_fatal( $error ); } self::record_crash( $error ); } return $message; } /** * Shutdown function: catch the fatal WordPress's own handler did not show. */ public static function on_shutdown(): void { $error = error_get_last(); if ( is_array( $error ) ) { if ( class_exists( \GripMCP\Premium\Paid_Debug_Logging::class ) ) { \GripMCP\Premium\Paid_Debug_Logging::log_fatal( $error ); } self::record_crash( $error ); } } /** * Store a fatal error. A repeat of the last one is counted, not stored * again, so one broken page cannot push every other crash out. * * @param array $error error_get_last() shape. */ private static function record_crash( array $error ): void { if ( self::$crash_recorded || ! isset( $error['type'] ) || ! ( (int) $error['type'] & self::FATAL_TYPES ) ) { return; } // A page WordPress loads to check a PHP change (its own file editor, // or GripMCP's check after a file write) is not a visitor's crash: the // change may already be undone, and the check reports the error itself. if ( defined( 'WP_SANDBOX_SCRAPING' ) && WP_SANDBOX_SCRAPING ) { return; } self::$crash_recorded = true; try { // A request that died inside a database transaction (a batch of a // search and replace, say) would take this record back with the rest // when its connection closes, so that transaction is ended first, // before the list is read. Nothing in it was going to be kept anyway. global $wpdb; if ( $wpdb instanceof \wpdb ) { $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->query( 'ROLLBACK' ); $wpdb->suppress_errors( $suppress ); } // An uncaught exception's message carries its whole stack trace; // the location is stored separately, so keep the first part. $message = (string) ( $error['message'] ?? '' ); $trace = strpos( $message, 'Stack trace:' ); if ( false !== $trace ) { $message = substr( $message, 0, $trace ); } $message = trim( (string) preg_replace( '/\s+/', ' ', self::strip_root( $message ) ) ); $message = substr( $message, 0, self::MAX_MESSAGE ); $file = self::strip_root( (string) ( $error['file'] ?? '' ) ); $line = (int) ( $error['line'] ?? 0 ); $crashes = self::crashes(); $last = end( $crashes ); if ( is_array( $last ) && ( $last['message'] ?? '' ) === $message && ( $last['file'] ?? '' ) === $file && (int) ( $last['line'] ?? 0 ) === $line ) { $crashes[ count( $crashes ) - 1 ]['count'] = (int) ( $last['count'] ?? 1 ) + 1; $crashes[ count( $crashes ) - 1 ]['time'] = gmdate( 'c' ); } else { $crashes[] = array( 'time' => gmdate( 'c' ), 'type' => self::type_name( (int) $error['type'] ), 'message' => $message, 'file' => $file, 'line' => $line, 'request' => self::request_path(), 'count' => 1, ); } update_option( self::CRASHES_OPTION, array_slice( $crashes, -self::MAX_CRASHES ), false ); } catch ( \Throwable $e ) { // Nothing more can be done in a dying request. return; } } /** * Stored state with every field present and typed. * * @return array{until: int, file: string, dir: string, started: int, started_by: int} */ public static function state(): array { $state = get_option( self::OPTION, array() ); $state = is_array( $state ) ? $state : array(); return array( 'until' => (int) ( $state['until'] ?? 0 ), 'file' => isset( $state['file'] ) && is_string( $state['file'] ) ? $state['file'] : '', 'dir' => isset( $state['dir'] ) && is_string( $state['dir'] ) ? $state['dir'] : '', 'started' => (int) ( $state['started'] ?? 0 ), 'started_by' => (int) ( $state['started_by'] ?? 0 ), ); } /** * Whether the paid version's temporary logging is on and has not expired. * * @param array{until: int, file: string, dir: string} $state Stored state. */ public static function is_active( array $state ): bool { return $state['until'] > time() && self::valid_file( $state['file'], $state['dir'] ); } /** * Only a file the paid version's enable() named, in the folder it chose, * is written to or read. Both are stored in an option, so a path anywhere else (a .php file, * say) would let error text be written as code. A state written before * the folder moved to uploads has no folder, so it is never used. * * @param string $file Candidate path. * @param string $dir The folder stored with it. */ public static function valid_file( string $file, string $dir ): bool { return '' !== $file && '' !== $dir && self::DIR_NAME === basename( $dir ) && wp_normalize_path( dirname( $file ) ) === wp_normalize_path( $dir ) && 1 === preg_match( '/^debug-[a-z0-9]{32}\.log$/', basename( $file ) ); } /** * Absolute path of the log folder: gripmcp-logs in this site's uploads * folder, or '' when WordPress cannot say where that is. */ public static function dir(): string { $uploads = wp_upload_dir( null, false ); if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) ) { return ''; } return untrailingslashit( (string) $uploads['basedir'] ) . '/' . self::DIR_NAME; } /** * The folder of the temporary log (or where the next one goes), as the * tools show it, e.g. "wp-content/uploads/gripmcp-logs". */ public static function log_folder(): string { $state = self::state(); return self::folder_label( '' !== self::log_file() ? $state['dir'] : self::dir() ); } /** * A log folder as the tools and messages show it: from the site's root, * without the server's own folders. * * @param string $dir Absolute folder path. */ public static function folder_label( string $dir ): string { if ( '' === $dir ) { return self::DIR_NAME; } $root = wp_normalize_path( ABSPATH ); $dir = wp_normalize_path( $dir ); return 0 === strpos( $dir, $root ) ? substr( $dir, strlen( $root ) ) : self::DIR_NAME; } /** * Remove the server-specific part of any path in the text. * * @param string $text Text that may contain absolute paths. */ private static function strip_root( string $text ): string { $root = rtrim( ABSPATH, '/\\' ); return str_replace( array( $root . DIRECTORY_SEPARATOR, $root . '/', wp_normalize_path( $root ) . '/' ), '', $text ); } /** * Path of the request that crashed, without the query string (which can * carry tokens). */ private static function request_path(): string { if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'wp-cli'; } $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; return substr( (string) strtok( $uri, '?' ), 0, 200 ); } /** * Readable name for a PHP error type. * * @param int $type Error type bit. */ private static function type_name( int $type ): string { $names = array( E_ERROR => 'Fatal error', E_PARSE => 'Parse error', E_CORE_ERROR => 'Core error', E_COMPILE_ERROR => 'Compile error', E_USER_ERROR => 'User error', E_RECOVERABLE_ERROR => 'Recoverable error', ); return $names[ $type ] ?? 'Error'; } }