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 / ajax / AjaxAdminEndpointSupport.php

AjaxAdminEndpointSupport.php in 404 Solution trunk, at includes/ajax/AjaxAdminEndpointSupport.php

472 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /**
9 * Cross-cutting infrastructure consumed by every admin-AJAX endpoint
10 * handler in includes/ajax/Ajax_*.php. Owns the request lifecycle that
11 * surrounds a response: debug-context start, response-sent marker, output
12 * buffer management, admin-status fallback
13 * resolution, request reader, failure logging shim, and view instance
14 * resolution. Actually emitting the JSON response (header/ledger stamping,
15 * the encode+echo boundary, output-buffer drain, connection-detach, exit)
16 * is ABJ_404_Solution_AjaxResponseEmitter's own cohesive responsibility --
17 * see that class for why it is split out rather than kept here.
18 *
19 * Shared cross-cutting helpers (fatal-error
20 * classifier, debug-context starter, admin-nonce action list) for the
21 * per-endpoint admin-table AJAX handlers, so each handler can own a single
22 * endpoint's logic in its own file while reusing this common surface.
23 *
24 * Named without the `Ajax_` endpoint prefix (it is infrastructure, not a
25 * request handler) so it stays out of the per-endpoint contract / auth /
26 * adversarial structural test globs, matching AjaxAdminEndpointRegistrar and
27 * AjaxSecurityGate. It registers no `wp_ajax_*` action and has no request
28 * entry point; every method here runs only after a real handler has already
29 * authorized the request. Carrying the handler prefix made the auth glob scan
30 * this file as though it were an endpoint, which it passed by coincidence
31 * (a substring in policy code) until that code moved to
32 * AdminStatusFallbackResolver.
33 */
34 class ABJ_404_Solution_AjaxAdminEndpointSupport {
35
36 /**
37 * Admin nonce action verbs JS call sites consume. Keep in sync with
38 * view_updater_nonce_refresh.js NONCE_DATA_ATTRS and the wp_verify_nonce()
39 * calls in each handler + in Ajax_TrendData.php.
40 * @return string[]
41 */
42 public static function adminNonceActions(): array {
43 return array('abj404_updatePaginationLink',
44 'abj404_refreshStatsDashboard', 'abj404_refreshHealthBar',
45 'abj404_runLazyBackfill', 'abj404_trendData');
46 }
47
48 /**
49 * @param int $type
50 * @return bool
51 */
52 public static function isFatalErrorType($type) {
53 $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR);
54 return in_array($type, $fatalTypes, true);
55 }
56
57 /**
58 * Verify an admin AJAX nonce and plugin-admin authorization, then emit
59 * this layer's diagnostic error envelope on failure.
60 *
61 * @param string $nonceAction
62 * @param array<string, mixed> $context
63 * @param string $handlerName Human-readable handler label for logs.
64 * @param array<string, mixed> $options Passed to AjaxSecurityGate.
65 * @return bool True when authorized.
66 */
67 public static function requireAdminWithNonceOrRespond(
68 string $nonceAction,
69 array $context,
70 string $handlerName,
71 array $options = array()
72 ): bool {
73 $checkpointRequestId = ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestId($context);
74 $gate = function_exists('abj_service_optional') ? abj_service_optional('ajax_security_gate') : null;
75 if (!is_object($gate) || !method_exists($gate, 'authorizeAdminWithNonce')) {
76 ABJ_404_Solution_AjaxCheckpointLogger::record($checkpointRequestId, 'auth_service_unavailable_branch', array('handler' => $handlerName));
77 self::safeLogAjaxFailure('AJAX authorization service unavailable in ' . $handlerName . '.', $context);
78 self::markAjaxResponseSent();
79 self::getAndClearAjaxBufferedOutput();
80 ABJ_404_Solution_AjaxResponseEmitter::sendJsonResponseAndExit(
81 ABJ_404_Solution_AjaxErrorEnvelope::build('Unauthorized', null, false),
82 403
83 );
84 return false;
85 }
86
87 $result = ABJ_404_Solution_AjaxCheckpointLogger::around(
88 $checkpointRequestId,
89 'auth_check',
90 static fn() => $gate->authorizeAdminWithNonce($nonceAction, $options)
91 );
92 if (is_array($result) && !empty($result['ok'])) {
93 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
94 $GLOBALS['abj404_ajax_context']['is_plugin_admin'] = true;
95 }
96 return true;
97 }
98
99 $code = self::authResultString($result, 'code', 'unauthorized');
100 $message = self::authResultString($result, 'message', 'Unauthorized');
101 $status = self::authResultStatus($result, 403);
102
103 $summary = $code === 'invalid_nonce'
104 ? 'AJAX invalid nonce in ' . $handlerName . '.'
105 : 'AJAX unauthorized in ' . $handlerName . '.';
106 ABJ_404_Solution_AjaxCheckpointLogger::record($checkpointRequestId, 'auth_failure_branch', array('code' => $code, 'status' => $status));
107 self::safeLogAjaxFailure($summary, $context);
108 self::markAjaxResponseSent();
109 self::getAndClearAjaxBufferedOutput();
110 ABJ_404_Solution_AjaxResponseEmitter::sendJsonResponseAndExit(
111 ABJ_404_Solution_AjaxErrorEnvelope::build($message, null, false),
112 $status
113 );
114 return false;
115 }
116
117 /**
118 * Read one string field out of an authorization result, falling back when
119 * it is absent or the wrong type.
120 *
121 * $result is deliberately typed `mixed` rather than the authorizer's
122 * declared `array{ok: bool, code: string, ...}` shape, because that shape
123 * is not what this code can count on at runtime: the gate is resolved from
124 * the service container and accepted on nothing stronger than
125 * is_object() + method_exists(), so any object exposing the method name
126 * reaches this branch and may return whatever it likes. Inlined at the
127 * call site the guards read as dead code -- the declared shape guarantees
128 * the keys -- and static analysis says so; taking the value through a
129 * `mixed` parameter is what makes the check honest instead of removing it
130 * from an authorization failure path.
131 *
132 * @param mixed $result
133 */
134 private static function authResultString($result, string $key, string $fallback): string {
135 if (!is_array($result) || !isset($result[$key]) || !is_string($result[$key])) {
136 return $fallback;
137 }
138 return $result[$key];
139 }
140
141 /**
142 * The HTTP status from an authorization result, or $fallback when it is
143 * absent or non-scalar. Separate from authResultString() because the
144 * accepted input is wider (any scalar, intval()'d) and the output type is
145 * different; see that method for why $result is typed `mixed`.
146 *
147 * @param mixed $result
148 */
149 private static function authResultStatus($result, int $fallback): int {
150 if (!is_array($result) || !isset($result['status']) || !is_scalar($result['status'])) {
151 return $fallback;
152 }
153 return intval($result['status']);
154 }
155
156 /**
157 * @param object|null $abj404view
158 * @return object
159 */
160 public static function resolveViewInstance(&$abj404view) {
161 if (is_object($abj404view)) {
162 return $abj404view;
163 }
164 if (function_exists('abj_service_optional')) {
165 $resolved = abj_service_optional('view');
166 if (is_object($resolved)) {
167 $abj404view = $resolved;
168 return $abj404view;
169 }
170 }
171 throw new Exception('ABJ404 view service not initialized (abj404view is null).'); // allow-raw-error: programmer assertion preserved verbatim from prior ViewUpdater behavior; signals service-container misconfiguration, not user-facing
172 }
173
174 /** @return ABJ_404_Solution_AjaxFailureLogger */
175 private static function ajaxFailureLogger() {
176 $logger = function_exists('abj_service_optional') ? abj_service_optional('ajax_failure_logger') : null;
177 if ($logger instanceof ABJ_404_Solution_AjaxFailureLogger) {
178 return $logger;
179 }
180 $logging = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
181 return new ABJ_404_Solution_AjaxFailureLogger(is_object($logging) ? $logging : null);
182 }
183
184 /**
185 * @param mixed $sql
186 * @return string
187 */
188 public static function redactSqlShape($sql) {
189 return self::ajaxFailureLogger()->redactSqlShape($sql);
190 }
191
192 /**
193 * @param string $summary
194 * @param mixed $details
195 * @param \Throwable|null $throwable
196 * @return void
197 */
198 public static function safeLogAjaxFailure($summary, $details = null, $throwable = null) {
199 self::safeLogAjaxFailureBranch(
200 'failure_branch',
201 $summary,
202 static fn() => $details,
203 $throwable
204 );
205 }
206
207 /**
208 * Persist a post-authorization failure fingerprint before constructing
209 * details, then trace every shared failure-logging boundary.
210 *
211 * @param string $branch
212 * @param string $summary
213 * @param callable(): mixed $detailsFactory
214 * @param \Throwable|null $throwable
215 * @return mixed The constructed details, for the caller's response path.
216 */
217 public static function safeLogAjaxFailureBranch(
218 string $branch,
219 $summary,
220 callable $detailsFactory,
221 $throwable = null
222 ) {
223 return ABJ_404_Solution_PostAuthorizationFailureTracer::trace(
224 $branch,
225 $throwable,
226 $detailsFactory,
227 static function ($details) use ($summary, $throwable) {
228 $logger = ABJ_404_Solution_PostAuthorizationFailureTracer::aroundOperation(
229 'logger_resolution',
230 static fn() => self::ajaxFailureLogger()
231 );
232 $logger->setOperationTracer(
233 static fn(string $operation, callable $work) =>
234 ABJ_404_Solution_PostAuthorizationFailureTracer::aroundOperation(
235 $operation,
236 $work
237 )
238 );
239 $logger->safeLogAjaxFailure($summary, $details, $throwable);
240 return $details;
241 }
242 );
243 }
244
245 /**
246 * @param Throwable $throwable
247 * @return array<string, mixed>|null
248 */
249 public static function extractViewQueryDiagnostics(Throwable $throwable) {
250 return self::ajaxFailureLogger()->extractViewQueryDiagnostics($throwable);
251 }
252
253 /**
254 * @param array<string, mixed> $context
255 * @param string $source Identifier for the originating handler, recorded in the global context.
256 * @return array<string, mixed>
257 */
258 public static function startAjaxDebugContext($context, string $source = 'ViewUpdater') {
259 if (!is_array($context)) {
260 $context = array();
261 }
262
263 $context['abj404_context_source'] = $source;
264 $context['ajax_expected_json'] = true;
265 $context['response_sent'] = false;
266 $context['ob_level_before'] = ob_get_level();
267 // Response-head bookkeeping is request state, and this is the one
268 // arming point every JSON endpoint passes through.
269 ABJ_404_Solution_JsonResponseHead::resetForRequest();
270
271 // Prevent WordPress's "critical error" HTML page from masking details for AJAX calls.
272 if (!headers_sent()) {
273 if (array_key_exists('action', $context) && is_string($context['action'])) {
274 header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $context['action']));
275 }
276 if (array_key_exists('subpage', $context) && is_string($context['subpage']) && $context['subpage'] !== '') {
277 header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $context['subpage']));
278 }
279 }
280 // Outside that guard on purpose. A header genuinely cannot be set once
281 // output has started, but display_errors is PHP_INI_ALL with no such
282 // restriction, so sharing the guard stopped suppressing notices on
283 // exactly the request that had ALREADY emitted stray bytes -- the one
284 // whose body was already suspect.
285 //
286 // Recorded rather than assumed, because ini_set is refusable: a host
287 // carrying it in disable_functions is a live user environment. Without
288 // this, PHP notices print into a body every consumer parses as JSON,
289 // and in a support payload that is indistinguishable from the
290 // transport corruption the canary ladder is investigating.
291 $context['display_errors_suppressed'] = ABJ_404_Solution_PhpRuntimeCapabilityAdapter::setIni(
292 array('directive' => 'display_errors', 'value' => '0')) !== false;
293 if (apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'viewUpdater_startAjaxDebugContext'))) {
294 @ob_start();
295 }
296
297 $GLOBALS['abj404_ajax_context'] = $context;
298 // diagnosticRequestId, not instrumentedRequestId: this arming point is
299 // shared by the table endpoint AND the canary ladder, and the
300 // instrumented predicate answers for ajaxUpdatePaginationLinks alone.
301 // Using it here left ajaxRunCanaryStep with every tracer null, so the
302 // ladder -- which exists only to re-run the same boot/auth/dispatch
303 // path and produce a COMPARABLE trace -- came back with none of the
304 // records it is compared against. Both predicates still require the
305 // debug opt-in, so a default GA request stays inert either way.
306 $diagnosticsEnabled = ABJ_404_Solution_AjaxDiagnosticRequestPolicy::diagnosticRequestId($context) !== '';
307 self::configureDiagnosticOperationTracers($diagnosticsEnabled);
308 return $context;
309 }
310
311 /**
312 * Arm retry-only diagnostics after the endpoint has authorized the user.
313 *
314 * Raw retryCount input is insufficient: callers must reach this method
315 * after nonce and plugin-admin checks. The policy validates the action,
316 * bounded retry count, and this internal marker before returning an ID.
317 *
318 * @param array<string, mixed> $context
319 * @return array<string, mixed>
320 */
321 public static function armAuthorizedRetryDiagnostics(array $context): array {
322 $context['diagnostic_retry_authorized'] = true;
323 $GLOBALS['abj404_ajax_context'] = $context;
324 $diagnosticsEnabled = ABJ_404_Solution_AjaxDiagnosticRequestPolicy::diagnosticRequestId($context) !== '';
325 self::configureDiagnosticOperationTracers($diagnosticsEnabled);
326 return $context;
327 }
328
329 /** Enable or clear the shared operation tracer callbacks for this request. */
330 private static function configureDiagnosticOperationTracers(bool $diagnosticsEnabled): void {
331 $fileTracer = $diagnosticsEnabled
332 ? static fn(string $operation, string $path, array $fields, callable $work) =>
333 ABJ_404_Solution_TemplateFileReadTracer::trace($operation, $path, $fields, $work)
334 : null;
335 $routineTracer = $diagnosticsEnabled
336 ? static fn(string $operation, array $fields, callable $work) =>
337 ABJ_404_Solution_RoutineLogTracer::trace($operation, $fields, $work)
338 : null;
339 $authorizationTracer = $diagnosticsEnabled
340 ? static fn(string $authorizationOperation, string $routineOperation, callable $work) =>
341 ABJ_404_Solution_AuthorizationLogTracer::aroundRoutineOperation(
342 $authorizationOperation, $routineOperation, $work)
343 : null;
344 $sortReadinessTracer = $diagnosticsEnabled
345 ? static fn(string $operation, array $fields, callable $work) =>
346 ABJ_404_Solution_SortReadinessTracer::trace($operation, $fields, $work)
347 : null;
348 $statusCountTracer = $diagnosticsEnabled
349 ? static fn(string $operation, array $fields, callable $work) =>
350 ABJ_404_Solution_StatusCountsForegroundTracer::trace($operation, $fields, $work)
351 : null;
352 ABJ_404_Solution_FileSystemService::setOperationTracer($fileTracer);
353 ABJ_404_Solution_RoutineLoggingBridge::setTracer($routineTracer);
354 ABJ_404_Solution_RoutineLoggingBridge::setAuthorizationTracer($authorizationTracer);
355 ABJ_404_Solution_RedirectsDenormSchemaReadiness::setOperationTracer($sortReadinessTracer);
356 ABJ_404_Solution_StatusCountsRepository::setOperationTracer($statusCountTracer);
357 ABJ_404_Solution_StatusCountsRefreshCoordinator::setOperationTracer($statusCountTracer);
358 ABJ_404_Solution_CronScheduler::setStatusCountOperationTracer($statusCountTracer);
359 }
360
361 /** @return void */
362 public static function markAjaxResponseSent() {
363 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
364 $GLOBALS['abj404_ajax_context']['response_sent'] = true;
365 }
366 }
367
368 /** @return string */
369 public static function getAndClearAjaxBufferedOutput() {
370 // This filter dispatches foreign WordPress callbacks (named + `all`)
371 // before the output buffer is read or drained. On the instrumented
372 // table endpoint the dispatch is bracketed and every callback attributed;
373 // off it, traceDispatch() is a byte-identical pass-through.
374 $shouldManageBuffer = ABJ_404_Solution_ResponseControlFilterTracer::traceDispatch(
375 'abj404_should_manage_output_buffer',
376 static function () {
377 return apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'viewUpdater_getAndClearAjaxBufferedOutput'));
378 }
379 );
380 if (!$shouldManageBuffer) {
381 return '';
382 }
383
384 $checkpointRequestId = ABJ_404_Solution_AjaxDiagnosticRequestPolicy::instrumentedRequestIdFromGlobalContext();
385 $out = '';
386 if (ob_get_level() > 0) {
387 if ($checkpointRequestId === '') {
388 $out = (string)ob_get_contents();
389 } else {
390 $out = (string)ABJ_404_Solution_AjaxCheckpointLogger::around(
391 $checkpointRequestId,
392 'ob_read',
393 static function () {
394 return ob_get_contents();
395 },
396 self::outputBufferCheckpointFields()
397 );
398 }
399 }
400
401 $minLevel = 0;
402 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
403 $minLevel = array_key_exists('ob_level_before', $GLOBALS['abj404_ajax_context'])
404 ? intval($GLOBALS['abj404_ajax_context']['ob_level_before']) : 0;
405 }
406 // Bounded with a stall check, for the same reason as the response tail:
407 // each iteration here can cost a checkpoint write, so an unbounded
408 // drain over a buffer that refuses to close burns CPU indefinitely.
409 ABJ_404_Solution_OutputBufferDrain::drainTo($minLevel, static function () use ($checkpointRequestId) {
410 if ($checkpointRequestId === '') {
411 @ob_end_clean();
412 return;
413 }
414 ABJ_404_Solution_AjaxCheckpointLogger::around(
415 $checkpointRequestId,
416 'ob_clear',
417 static function () {
418 @ob_end_clean();
419 },
420 self::outputBufferCheckpointFields()
421 );
422 });
423
424 return $out;
425 }
426
427 /**
428 * Identify the active output-buffer stack before a read or clean call can
429 * invoke a foreign handler. Kept free of buffered content so diagnostics
430 * cannot expose response data.
431 *
432 * @return array{ob_level: int, ob_length: int, ob_handlers: array<int, string>}
433 */
434 private static function outputBufferCheckpointFields(): array {
435 $length = ob_get_length();
436 $handlers = ob_list_handlers();
437 return array(
438 'ob_level' => ob_get_level(),
439 'ob_length' => is_int($length) ? $length : 0,
440 'ob_handlers' => is_array($handlers) ? $handlers : array(),
441 );
442 }
443
444 /** @return ABJ_404_Solution_RequestInputNormalizer */
445 public static function getRequestReader() {
446 $container = ABJ_404_Solution_ServiceContainer::getInstance();
447 if ($container->has('request_input_normalizer')) {
448 /** @var ABJ_404_Solution_RequestInputNormalizer $requestReader */
449 $requestReader = $container->get('request_input_normalizer');
450 return $requestReader;
451 }
452 /** @var ABJ_404_Solution_RequestInputNormalizer $requestReader */
453 $requestReader = abj_service('request_input_normalizer');
454 return $requestReader;
455 }
456
457 /**
458 * Re-check admin status after an exception path. PluginLogic may be the
459 * broken component, so fall back to wp_get_current_user() and
460 * is_super_admin() to give real admins detailed error diagnostics.
461 *
462 * @param bool $isPluginAdmin Current best-known admin status (e.g. from before the throw).
463 * @param bool $includeWpUserFallback If true, also fall back to wp_get_current_user() and
464 * is_super_admin() (used only by getPaginationLinks; other
465 * handlers stop at the PluginLogic re-check).
466 * @return bool
467 */
468 public static function resolveIsPluginAdminFallback(bool $isPluginAdmin, bool $includeWpUserFallback = false): bool {
469 return ABJ_404_Solution_AdminStatusFallbackResolver::resolve($isPluginAdmin, $includeWpUserFallback);
470 }
471 }
472