$context Optional operation context. * @return array */ public function build_snapshot( array $context = [] ): array { $theme = function_exists( 'wp_get_theme' ) ? wp_get_theme() : null; $theme_name = is_object( $theme ) && method_exists( $theme, 'get' ) ? (string) $theme->get( 'Name' ) : 'Unknown'; $site_url = function_exists( 'home_url' ) ? (string) home_url() : ( function_exists( 'get_site_url' ) ? (string) get_site_url() : '' ); $site_host = (string) ( wp_parse_url( $site_url, PHP_URL_HOST ) ?: $site_url ); $plugins = get_option( 'active_plugins', [] ); $plugins = is_array( $plugins ) ? array_values( array_map( 'sanitize_text_field', $plugins ) ) : []; $snapshot = [ 'generated_at' => gmdate( 'c' ), 'plugin_version' => defined( 'SHIFTORA_VERSION' ) ? SHIFTORA_VERSION : 'unknown', 'public_version' => defined( 'SHIFTORA_PUBLIC_VERSION' ) ? SHIFTORA_PUBLIC_VERSION : 'unknown', 'wordpress_version' => function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'version' ) : 'unknown', 'php_version' => PHP_VERSION, 'site_host' => $site_host, 'active_theme' => $theme_name, 'active_plugins' => $plugins, 'active_plugins_count' => count( $plugins ), 'memory_limit' => (string) ini_get( 'memory_limit' ), 'max_execution_time' => (string) ini_get( 'max_execution_time' ), 'host_upload_limits' => HostUploadLimits::from_php()->report(), 'current_operation' => sanitize_key( (string) ( $context['current_operation'] ?? 'idle' ) ), 'last_actions' => array_values( array_slice( is_array( $context['last_actions'] ?? null ) ? $context['last_actions'] : [], -3 ) ), 'auto_report_enabled' => $this->auto_report_enabled(), 'recent_errors' => $this->get_recent_errors( 5 ), 'latest_php_fatal' => $this->latest_php_fatal(), 'last_report' => get_option( self::LAST_REPORT_OPTION, [] ), ]; return $this->error_reporter()->sanitize_context( $snapshot ); } /** * Records a recent sanitized error for the Help screen. * * @param array $error Normalized error payload. */ public function record_error( array $error ): void { $stored = get_option( self::RECENT_ERRORS_OPTION, [] ); $stored = is_array( $stored ) ? $stored : []; $sanitized = $this->error_reporter()->sanitize_context( $error ); $last = ! empty( $stored ) ? end( $stored ) : null; if ( is_array( $last ) && (string) ( $last['code'] ?? '' ) === (string) ( $sanitized['code'] ?? '' ) && (string) ( $last['stage'] ?? '' ) === (string) ( $sanitized['stage'] ?? '' ) && (string) ( $last['message'] ?? '' ) === (string) ( $sanitized['message'] ?? '' ) ) { $stored[ array_key_last( $stored ) ] = array_merge( $last, $sanitized ); } else { $stored[] = $sanitized; } $stored = array_slice( $stored, -25 ); update_option( self::RECENT_ERRORS_OPTION, $stored, false ); } /** * Returns recent error history, newest first. * * @return array> */ public function get_recent_errors( int $limit = 10 ): array { $stored = get_option( self::RECENT_ERRORS_OPTION, [] ); if ( ! is_array( $stored ) ) { return []; } $recent = array_reverse( array_slice( $stored, -max( 1, $limit ) ) ); return array_values( array_map( fn( array $error ): array => $this->presentable_error( $error ), array_filter( $recent, 'is_array' ) ) ); } /** * Sends a user-triggered issue report to support. * * A local copy is kept when the mail fails or $keep_local is set. * * @param array $payload Report payload from the UI. * @return array */ public function report_issue( array $payload, bool $keep_local = false ): array { $summary = sanitize_text_field( (string) ( $payload['summary'] ?? __( 'Shiftora issue report', 'shiftora' ) ) ); if ( '' === $summary ) { $summary = __( 'Shiftora issue report', 'shiftora' ); } $snapshot = $this->build_snapshot( $payload ); $details = $this->error_reporter()->sanitize_context( $payload ); $message = "Shiftora Issue Report\n\n"; $message .= 'Summary: ' . $summary . "\n\n"; $message .= "Diagnostics:\n" . (string) wp_json_encode( $snapshot, JSON_PRETTY_PRINT ) . "\n\n"; $message .= "Report Payload:\n" . (string) wp_json_encode( $details, JSON_PRETTY_PRINT ) . "\n"; $sent = function_exists( 'wp_mail' ) ? (bool) wp_mail( self::REPORT_RECIPIENT, 'Shiftora issue report, ' . $summary, $message, [ 'Content-Type: text/plain; charset=UTF-8' ] ) : false; $result = [ 'summary' => $summary, 'sent' => $sent, 'saved_locally' => false, 'delivery' => $sent ? 'support_mail' : 'failed', 'message' => $sent ? __( 'Your support report has been sent.', 'shiftora' ) : __( 'Shiftora could not send the report and could not save a copy for manual review. Email support directly from the Help screen.', 'shiftora' ), 'sent_at' => gmdate( 'c' ), 'recipient' => self::REPORT_RECIPIENT, 'local_report_path' => '', ]; if ( $keep_local || ! $sent ) { $path = $this->persist_local_report( [ 'summary' => $summary, 'reported_at' => gmdate( 'c' ), 'recipient' => self::REPORT_RECIPIENT, 'diagnostics' => $snapshot, 'details' => $details, ] ); if ( '' !== $path ) { $result['saved_locally'] = true; $result['local_report_path'] = $path; $result['delivery'] = $sent ? 'support_mail' : 'local_fallback'; $result['message'] = $sent ? __( 'Your support report has been sent, and a local copy was kept on this site.', 'shiftora' ) : __( 'Shiftora could not send the report, so it saved a local copy on this site. You can review diagnostics or copy the details manually.', 'shiftora' ); } } update_option( self::LAST_REPORT_OPTION, $result, false ); return $result; } /** Where a report goes; public so the interface can show it. */ public function report_recipient(): string { return self::REPORT_RECIPIENT; } public function auto_report_enabled(): bool { return (bool) get_option( self::AUTO_REPORT_OPTION, false ); } public function update_auto_report( bool $enabled ): void { update_option( self::AUTO_REPORT_OPTION, $enabled ? 1 : 0, false ); } private function error_reporter(): ErrorReporter { return $this->errors ?? new ErrorReporter(); } /** * Writes the full report under uploads so it survives a mail failure. * Failures are silent: the result already says whether a copy was saved. * * @param array $report * @return string Local file path, or '' when nothing could be written. */ private function persist_local_report( array $report ): string { DataDirectory::adopt_legacy_diagnostics( $this->uploads_base ); $dir = DataDirectory::ensure( DataDirectory::DIAGNOSTICS, $this->uploads_base ); if ( '' === $dir ) { return ''; } $this->purge_expired_reports( $dir ); $content = (string) wp_json_encode( $report, JSON_PRETTY_PRINT ); $file = sprintf( 'issue-%s-%s.json', gmdate( 'ymd-His' ), substr( hash( 'sha256', $content ), 0, 12 ) ); $path = $dir . '/' . $file; return Filesystem::write( $path, $content, DataDirectory::root( $this->uploads_base ) ) ? $path : ''; } /** Drops reports older than 30 days; best-effort, so a locked directory is skipped. */ private function purge_expired_reports( string $dir ): void { $files = glob( $dir . '/issue-*.json' ); if ( ! is_array( $files ) ) { return; } $cutoff = time() - 30 * 86400; foreach ( $files as $file ) { if ( ! is_string( $file ) || ! is_file( $file ) ) { continue; } $mtime = @filemtime( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes a log file's mtime for age pruning; a missing file returns false and is skipped by the is_int check below. if ( is_int( $mtime ) && $mtime < $cutoff ) { wp_delete_file( $file ); } } } /** * Adds user-facing labels to a stored error. * * @param array $error * @return array */ private function presentable_error( array $error ): array { $code = sanitize_key( (string) ( $error['code'] ?? 'unknown_error' ) ); $severity = sanitize_key( (string) ( $error['severity'] ?? 'warning' ) ); $labels = [ 'network_timeout' => __( 'Connection slowed down', 'shiftora' ), 'local_backup_unavailable' => __( 'Local backup needs attention', 'shiftora' ), 'session_refresh_required' => __( 'Sign-in refresh expected', 'shiftora' ), 'self_migration_protected' => __( 'Plugin self-sync blocked', 'shiftora' ), 'authentication_failed' => __( 'Remote verification failed', 'shiftora' ), 'permission_denied' => __( 'Permission needs review', 'shiftora' ), 'integrity_guard' => __( 'Migration stopped to protect data', 'shiftora' ), 'environment_problem' => __( 'Server environment needs attention', 'shiftora' ), 'migration_stalled' => __( 'Migration paused unexpectedly', 'shiftora' ), 'file_transfer_failed' => __( 'File transfer error', 'shiftora' ), 'file_stage_race_detected' => __( 'File stage overlap', 'shiftora' ), 'unknown_error' => __( 'Unrecognized error', 'shiftora' ), ]; $reportable = ! in_array( $code, [ 'session_refresh_required' ], true ); $error['severity'] = '' !== $severity ? $severity : 'warning'; $error['display_label'] = $labels[ $code ] ?? __( 'Unrecognized error', 'shiftora' ); $error['display_status'] = 'critical' === $severity ? __( 'Critical', 'shiftora' ) : ( 'error' === $severity ? __( 'Needs review', 'shiftora' ) : __( 'Recovered', 'shiftora' ) ); $error['reportable'] = $reportable; return $error; } /** Reads the most recent PHP fatal line from debug.log. */ private function latest_php_fatal(): array { // WP_DEBUG_LOG may name the file; otherwise the log is wherever PHP was told to write it. $log = defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ? WP_DEBUG_LOG : (string) ini_get( 'error_log' ); if ( '' === $log || ! is_readable( $log ) ) { return []; } // Read only the tail: debug.log can reach gigabytes and this runs on every Help view. $size = (int) @filesize( $log ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Probes the debug.log size; a missing file casts to 0 and the empty-tail check below returns early. $tail = min( $size, 512 * KB_IN_BYTES ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Opens debug.log for the bounded tail read; a missing file returns false and is handled by the check below. $handle = @fopen( $log, 'rb' ); if ( false === $handle || $tail <= 0 ) { return []; } try { fseek( $handle, -$tail, SEEK_END ); $chunk = (string) fread( $handle, $tail ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- WP_Filesystem has no seek-based read; only the last 512 KB of the log is read to bound memory. } finally { fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the log handle opened for the bounded tail read above. } $lines = preg_split( '/\r\n|\n|\r/', $chunk, -1, PREG_SPLIT_NO_EMPTY ); if ( ! is_array( $lines ) || empty( $lines ) ) { return []; } // The first line of a mid-file tail is a fragment. if ( $tail < $size ) { array_shift( $lines ); } for ( $i = count( $lines ) - 1; $i >= 0; $i-- ) { $line = (string) $lines[ $i ]; if ( ! str_contains( $line, 'PHP Fatal error:' ) ) { continue; } $payload = [ 'line' => $line, ]; if ( preg_match( '/ in (.+) on line (\d+)$/', $line, $matches ) ) { $payload['exception_file'] = $matches[1]; $payload['exception_line'] = (int) $matches[2]; } return $payload; } return []; } }