PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/services/AdminFatalErrorResponder.php

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