PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / services / AdminFatalErrorResponder.php

AdminFatalErrorResponder.php in 404 Solution 4.3.0, at includes/services/AdminFatalErrorResponder.php

230 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Detects plugin-admin fatal requests, stores the fatal state, and renders the fallback page.
9 */
10 class ABJ_404_Solution_AdminFatalErrorResponder {
11
12 /**
13 * Prevent duplicate shutdown fallback output when multiple handlers run.
14 *
15 * @var bool
16 */
17 private static $adminFatalPageRendered = false;
18
19 /**
20 * Detect whether the current request is the plugin admin page.
21 *
22 * @return bool
23 */
24 public function isPluginAdminPageRequest(): bool {
25 if ($this->isCliRequest()) {
26 return false;
27 }
28
29 $page = $this->getRequestValue('page');
30 if ($page === '') {
31 return false;
32 }
33
34 $pluginPage = defined('ABJ404_PP') ? (string)ABJ404_PP : 'abj404_solution';
35 return $page === $pluginPage;
36 }
37
38 /**
39 * Persist the last admin fatal so we can show a notice on the next request.
40 *
41 * @param array<string,mixed> $lasterror
42 * @return void
43 */
44 public function stashAdminFatal(array $lasterror): void {
45 $payload = array(
46 'message' => array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : '') : '',
47 'file' => array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '') : '',
48 'line' => array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0,
49 'type' => array_key_exists('type', $lasterror) ? (is_int($lasterror['type']) ? $lasterror['type'] : 0) : 0,
50 'time' => $this->currentTime(),
51 'page' => $this->getRequestValue('page'),
52 'subpage' => $this->getRequestValue('subpage'),
53 );
54
55 $ttl = defined('HOUR_IN_SECONDS') ? HOUR_IN_SECONDS : 3600;
56 if (function_exists('set_transient')) {
57 // allow-cache-empty: admin fatal stashes must persist even when PHP omits message/file details from error_get_last().
58 set_transient('abj404_admin_fatal', $payload, $ttl);
59 return;
60 }
61
62 if (function_exists('update_option')) {
63 update_option('abj404_admin_fatal_fallback', $payload);
64 }
65 }
66
67 /**
68 * Render a small HTML fallback so fatal admin errors do not become a blank page.
69 *
70 * @param array<string,mixed> $lasterror
71 * @return void
72 */
73 public function renderAdminFatalFallback(array $lasterror): void {
74 if (self::$adminFatalPageRendered) {
75 return;
76 }
77 self::$adminFatalPageRendered = true;
78
79 $canShowDetails = $this->canShowDetails();
80 $this->clearOutputBuffersIfAllowed();
81 $this->sendFatalHeaders();
82 $settingsUrl = $this->settingsUrl();
83
84 $message = array_key_exists('message', $lasterror) ? (is_string($lasterror['message']) ? $lasterror['message'] : 'Fatal error') : 'Fatal error';
85 $file = array_key_exists('file', $lasterror) ? (is_string($lasterror['file']) ? $lasterror['file'] : '(unknown file)') : '(unknown file)';
86 $line = array_key_exists('line', $lasterror) ? (is_int($lasterror['line']) ? $lasterror['line'] : 0) : 0;
87
88 $templatePath = dirname(__DIR__) . '/html/adminFatalErrorFallback.html';
89 $templateContent = file_exists($templatePath) ? file_get_contents($templatePath) : false;
90 if (!is_string($templateContent)) {
91 echo '404 Solution: fatal error fallback template unavailable.';
92 return;
93 }
94
95 echo str_replace(
96 array('{settings_url}', '{details}'),
97 array(
98 htmlspecialchars($settingsUrl, ENT_QUOTES, 'UTF-8'),
99 $canShowDetails ? $this->renderDetails($message, $file, $line) : '',
100 ),
101 $templateContent
102 );
103 }
104
105 /** @return bool */
106 private function canShowDetails(): bool {
107 try {
108 return ABJ_404_Solution_PluginAdminAccessPolicy::currentUserCanAccessPluginAdmin();
109 } catch (Throwable $e) {
110 abj404_logPhpFallback(
111 'fatal-handler-fallback',
112 'admin fatal capability detection failed (code ' . $e->getCode() . '): ' . $e->getMessage()
113 );
114 }
115
116 return false;
117 }
118
119 /** @return void */
120 private function clearOutputBuffersIfAllowed(): void {
121 $shouldManageOb = function_exists('apply_filters')
122 ? apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'renderAdminFatalFallback'))
123 : true;
124 if (!$shouldManageOb) {
125 return;
126 }
127
128 while (ob_get_level() > 0) {
129 @ob_end_clean();
130 }
131 }
132
133 /** @return void */
134 private function sendFatalHeaders(): void {
135 if (headers_sent()) {
136 return;
137 }
138
139 if (function_exists('status_header')) {
140 status_header(500);
141 } elseif (function_exists('http_response_code')) {
142 http_response_code(500);
143 }
144 header('Content-Type: text/html; charset=UTF-8');
145 }
146
147 /** @return string */
148 private function settingsUrl(): string {
149 $settingsUrl = '?page=' . (defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution') . '&subpage=abj404_options';
150 if (function_exists('admin_url')) {
151 return admin_url('options-general.php' . $settingsUrl);
152 }
153
154 return $settingsUrl;
155 }
156
157 /**
158 * Best-effort scalar read from request arrays without depending on WP helpers.
159 *
160 * @param string $key
161 * @return string
162 */
163 private function getRequestValue(string $key): string {
164 $raw = null;
165 if (array_key_exists($key, $_GET)) {
166 $raw = $_GET[$key];
167 } elseif (array_key_exists($key, $_POST)) {
168 $raw = $_POST[$key];
169 } elseif (array_key_exists($key, $_REQUEST)) {
170 $raw = $_REQUEST[$key];
171 }
172
173 if (!is_scalar($raw)) {
174 return '';
175 }
176
177 return trim((string)$raw);
178 }
179
180 /**
181 * @return string
182 */
183 private function renderDetails(string $message, string $file, int $line): string {
184 $templatePath = dirname(__DIR__) . '/html/adminFatalErrorDetails.html';
185 $templateContent = file_exists($templatePath) ? file_get_contents($templatePath) : false;
186 if (!is_string($templateContent)) {
187 return '';
188 }
189
190 return str_replace(
191 array('{error_details}'),
192 array(htmlspecialchars($message . "\n" . $file . ':' . (string)$line, ENT_QUOTES, 'UTF-8')),
193 $templateContent
194 );
195 }
196
197 /** @return int */
198 private function currentTime(): int {
199 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
200 $clock = ABJ_404_Solution_ServiceContainer::safeGet('clock');
201 if (is_object($clock) && method_exists($clock, 'now')) {
202 try {
203 return (int)$clock->now();
204 } catch (Throwable $e) {
205 abj404_logPhpFallback(
206 'fatal-handler-fallback',
207 'admin fatal clock lookup failed (code ' . $e->getCode() . '): ' . $e->getMessage()
208 );
209 }
210 }
211 }
212
213 return abj_clock()->now();
214 }
215
216 /** @return bool */
217 private function isCliRequest(): bool {
218 if (PHP_SAPI !== 'cli' && PHP_SAPI !== 'phpdbg') {
219 return false;
220 }
221
222 // Real extension point: unusual hosts and test harnesses may need to
223 // exercise wp-admin request handling while PHP reports a CLI-like SAPI.
224 $allowAdminFatalDetection = function_exists('apply_filters')
225 ? apply_filters('abj404_error_handler_allow_admin_fatal_detection_in_cli', false)
226 : false;
227 return !$allowAdminFatalDetection;
228 }
229 }
230