PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / ViewUpdater.php

ViewUpdater.php in 404 Solution 4.1.19, at includes/ajax/ViewUpdater.php

1,474 lines 67.3 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 /* Funtcions supporting Ajax stuff. */
9
10 class ABJ_404_Solution_ViewUpdater {
11
12 use ABJ_404_Solution_AjaxFailureLoggingTrait;
13
14 private const INFLIGHT_STAGE_EVENT_LIMIT = 5000;
15
16 /** @var self|null */
17 private static $instance = null;
18
19 /** @return self */
20 public static function getInstance() {
21 if (self::$instance == null) {
22 self::$instance = new ABJ_404_Solution_ViewUpdater();
23 }
24
25 return self::$instance;
26 }
27
28 /** @return void */
29 static function init() {
30 $me = ABJ_404_Solution_ViewUpdater::getInstance();
31 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxUpdatePaginationLinks',
32 array($me, 'getPaginationLinks'));
33 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxWarmTableCache',
34 array($me, 'warmTableCache'));
35 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxRefreshStatsDashboard',
36 array($me, 'refreshStatsDashboard'));
37 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxRefreshHealthBar',
38 array($me, 'refreshHealthBar'));
39 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxFetchInflightStage',
40 array($me, 'fetchInflightStage'));
41 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxAdvanceViewBuild',
42 array($me, 'advanceViewBuild'));
43 ABJ_404_Solution_WPUtils::safeAddAction('wp_ajax_ajaxRefreshAdminNonces',
44 array($me, 'refreshAdminNonces'));
45 // wp_ajax_nopriv_ is for normal users
46 }
47
48 /**
49 * Admin nonce action verbs JS call sites consume. Keep in sync with
50 * view_updater_nonce_refresh.js NONCE_DATA_ATTRS and the wp_verify_nonce()
51 * calls below + in Ajax_TrendData.php.
52 * @return string[]
53 */
54 public static function adminNonceActions(): array {
55 return array('abj404_updatePaginationLink', 'abj404_fetchInflightStage',
56 'abj404_refreshStatsDashboard', 'abj404_refreshHealthBar', 'abj404_trendData');
57 }
58
59 /**
60 * B20: mint fresh admin AJAX nonces for the page so the JS retry helper
61 * recovers transparently from a 12-24h-idle expired nonce. No nonce on
62 * the request itself (the caller's nonce expired by definition); the
63 * userIsPluginAdmin() capability gate is the only authorisation - which
64 * also handles the genuinely-logged-out case (full page refresh needed).
65 * @return void
66 */
67 function refreshAdminNonces() {
68 $abj404logic = abj_service('plugin_logic');
69 $ctx = self::startAjaxDebugContext(array('action' => 'ajaxRefreshAdminNonces',
70 'request_uri' => $_SERVER['REQUEST_URI'] ?? '',
71 'user_id' => function_exists('get_current_user_id') ? get_current_user_id() : 0));
72 try {
73 if (!$abj404logic->userIsPluginAdmin()) {
74 self::safeLogAjaxFailure('AJAX unauthorized in ajaxRefreshAdminNonces.', $ctx);
75 self::markAjaxResponseSent();
76 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Unauthorized', null, false), 403);
77 return;
78 }
79 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('refresh_admin_nonces', 60, 60)) {
80 self::safeLogAjaxFailure('AJAX rate limit in ajaxRefreshAdminNonces.', $ctx);
81 self::markAjaxResponseSent();
82 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse(
83 'Rate limit exceeded. Please try again later.', null, false), 429);
84 return;
85 }
86 $nonces = array();
87 foreach (self::adminNonceActions() as $action) {
88 $nonces[$action] = wp_create_nonce($action);
89 }
90 self::markAjaxResponseSent();
91 self::sendJsonResponseAndExit(array('success' => true,
92 'data' => array('nonces' => $nonces)), 200);
93 } catch (Throwable $e) {
94 self::safeLogAjaxFailure('AJAX exception in ajaxRefreshAdminNonces.', $ctx, $e);
95 self::markAjaxResponseSent();
96 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse(
97 'Server error while refreshing nonces.', null, false), 500);
98 }
99 }
100
101 /**
102 * Validate a client-supplied request id used as the transient suffix for
103 * in-flight stage tracking. The id is only ever written into a transient
104 * key (`abj404_inflight_<id>`), never executed or logged verbatim — but we
105 * still constrain it to alphanumerics so a malformed payload cannot collide
106 * with other plugins' transients or blow past WP's 172-char option-name
107 * limit.
108 *
109 * @return string Sanitized id, or '' if missing/invalid.
110 */
111 private static function readClientRequestId() {
112 $raw = '';
113 if (isset($_REQUEST['requestId'])) {
114 $raw = $_REQUEST['requestId'];
115 }
116 if (!is_string($raw) || $raw === '') {
117 return '';
118 }
119 if (!preg_match('/\A[a-zA-Z0-9]{8,64}\z/', $raw)) {
120 return '';
121 }
122 return $raw;
123 }
124
125 /**
126 * Best-effort foreground lease for admin/browser-owned rebuilds. Failure
127 * only means cron may compete for the view-build lock; it must never break
128 * the admin table response itself.
129 *
130 * @param mixed $dao
131 * @return void
132 */
133 private static function tryClaimForegroundViewBuildLease($dao): void {
134 if (!is_object($dao) || !method_exists($dao, 'claimForegroundViewBuildLease')) {
135 return;
136 }
137 try {
138 $dao->claimForegroundViewBuildLease();
139 } catch (Throwable $e) {
140 self::safeLogAjaxFailure(
141 'claimForegroundViewBuildLease failed; cron may compete for the build lock.',
142 null,
143 $e
144 );
145 }
146 }
147
148 /**
149 * Update the in-flight stage marker for the current AJAX request. Sets
150 * `$context['stage']` and — when a client requestId is present — also
151 * writes a short-lived transient so a follow-up `ajaxFetchInflightStage`
152 * call can read which phase the server was in when a client-side timeout
153 * fired (no response, no body, no headers reach the browser).
154 *
155 * Transient TTL is intentionally short (60s) — diagnostics that arrive
156 * after a minute aren't useful for the user-visible error notice anyway.
157 *
158 * @param array<string, mixed> $context Passed by reference; mutated in place.
159 * @param string $stage Stage label (e.g. 'table_captured', 'paginationLinksTop').
160 * @return void
161 */
162 private static function setStage(&$context, $stage) {
163 if (!is_array($context)) {
164 $context = array();
165 }
166 $diagnostics = self::getStageDiagnostics($stage);
167 $context['stage'] = $stage;
168 $context['query_label'] = $diagnostics['query_label'];
169 $context['what_happening'] = $diagnostics['what_happening'];
170 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
171 $GLOBALS['abj404_ajax_context']['stage'] = $stage;
172 $GLOBALS['abj404_ajax_context']['query_label'] = $diagnostics['query_label'];
173 $GLOBALS['abj404_ajax_context']['what_happening'] = $diagnostics['what_happening'];
174 }
175
176 $requestId = isset($context['requestId']) && is_string($context['requestId']) ? $context['requestId'] : '';
177 if ($requestId === '') {
178 return;
179 }
180 if (!function_exists('set_transient')) {
181 return;
182 }
183 $event = array(
184 'stage' => (string)$stage,
185 'query_label' => $diagnostics['query_label'],
186 'what_happening' => $diagnostics['what_happening'],
187 'time_ms' => (int)round(microtime(true) * 1000),
188 );
189 $events = array();
190 if (function_exists('get_transient')) {
191 $existing = @get_transient('abj404_inflight_' . $requestId);
192 if (is_array($existing) && is_array($existing['events'] ?? null)) {
193 $events = $existing['events'];
194 }
195 }
196 $lastEvent = !empty($events) ? $events[count($events) - 1] : null;
197 $lastStage = is_array($lastEvent) && isset($lastEvent['stage']) && is_string($lastEvent['stage'])
198 ? $lastEvent['stage'] : '';
199 if ($lastStage !== (string)$stage) {
200 $events[] = $event;
201 if (count($events) > self::INFLIGHT_STAGE_EVENT_LIMIT) {
202 $events = array_slice($events, -self::INFLIGHT_STAGE_EVENT_LIMIT);
203 }
204 }
205 // Diagnostics — best effort. Never let a transient write failure
206 // mask the real query error we're trying to diagnose. The
207 // @-suppression converts any wpdb/network warning into a no-op.
208 @set_transient('abj404_inflight_' . $requestId, array(
209 'stage' => (string)$stage,
210 'query_label' => $diagnostics['query_label'],
211 'what_happening' => $diagnostics['what_happening'],
212 'events' => $events,
213 ), 60);
214 }
215
216 /**
217 * @param string $stage
218 * @return array{query_label: string, what_happening: string}
219 */
220 private static function getStageDiagnostics($stage) {
221 $map = array(
222 'table_redirects' => array(
223 'query_label' => 'getAdminRedirectsPageTable() -> read redirects rows from staged view snapshot',
224 'what_happening' => 'Loading Redirects table rows',
225 ),
226 'redirect_status_counts' => array(
227 'query_label' => 'getRedirectStatusCounts()',
228 'what_happening' => 'Counting Redirects status tabs',
229 ),
230 'table_captured' => array(
231 'query_label' => 'getCapturedURLSPageTable() -> read captured rows from staged view snapshot',
232 'what_happening' => 'Loading Captured 404 URLs table rows',
233 ),
234 'captured_status_counts' => array(
235 'query_label' => 'getCapturedStatusCounts()',
236 'what_happening' => 'Counting Captured 404 URLs status tabs',
237 ),
238 'table_logs' => array(
239 'query_label' => 'getAdminLogsPageTable() -> getLogRecords()',
240 'what_happening' => 'Loading Logs table rows',
241 ),
242 'paginationLinksTop' => array(
243 'query_label' => 'getPaginationLinks(top) -> read top pagination count from staged view snapshot',
244 'what_happening' => 'Rendering top pagination links',
245 ),
246 'paginationLinksBottom' => array(
247 'query_label' => 'getPaginationLinks(bottom) -> read bottom pagination count from staged view snapshot',
248 'what_happening' => 'Rendering bottom pagination links',
249 ),
250 'table_cache_rows' => array(
251 'query_label' => 'getRedirectsForView',
252 'what_happening' => 'Warming table row snapshot',
253 ),
254 'table_cache_count' => array(
255 'query_label' => 'getRedirectsForViewCount',
256 'what_happening' => 'Warming table count snapshot',
257 ),
258 'high_impact_count' => array(
259 'query_label' => 'getHighImpactCapturedCount()',
260 'what_happening' => 'Counting high-impact captured URLs',
261 ),
262 // Sub-stages of the staged view-build pipeline (see
263 // DataAccessTrait_ViewQueriesStaged::runStagedBuildOnce). These
264 // are emitted by markBuildStage() during cold-cache builds so the
265 // .abj404-refresh-status element can show step-by-step progress
266 // instead of a single frozen "stage 1" label for the whole build.
267 'staged_build_s1_create' => array(
268 'query_label' => 'CREATE TABLE wp_abj404_view_build',
269 'what_happening' => 'Creating build buffer (1/11)',
270 ),
271 'staged_build_s2_insert' => array(
272 'query_label' => 'INSERT INTO wp_abj404_view_build SELECT FROM wp_abj404_redirects',
273 'what_happening' => 'Bulk-loading redirects into build buffer (2/11)',
274 ),
275 'staged_build_s3_index_fd' => array(
276 'query_label' => 'ALTER TABLE wp_abj404_view_build ADD INDEX idx_fd_int',
277 'what_happening' => 'Adding pre-join indexes (3/11)',
278 ),
279 'staged_build_s4_update_posts' => array(
280 'query_label' => 'UPDATE wp_abj404_view_build LEFT JOIN wp_posts',
281 'what_happening' => 'Filling published-status from wp_posts (4/11)',
282 ),
283 'staged_build_s5_update_terms' => array(
284 'query_label' => 'UPDATE wp_abj404_view_build LEFT JOIN wp_terms',
285 'what_happening' => 'Filling published-status from wp_terms (5/11)',
286 ),
287 'staged_build_s6_update_home' => array(
288 'query_label' => 'UPDATE wp_abj404_view_build (HOME)',
289 'what_happening' => 'Filling HOME-typed redirects (6/11)',
290 ),
291 'staged_build_s7_update_external' => array(
292 'query_label' => 'UPDATE wp_abj404_view_build (EXTERNAL)',
293 'what_happening' => 'Filling EXTERNAL-typed redirects (7/11)',
294 ),
295 'staged_build_s8_update_special' => array(
296 'query_label' => 'UPDATE wp_abj404_view_build (404-displayed)',
297 'what_happening' => 'Filling 404-displayed redirects (8/11)',
298 ),
299 'staged_build_s9_update_hits' => array(
300 'query_label' => 'UPDATE wp_abj404_view_build LEFT JOIN wp_abj404_logs_hits',
301 'what_happening' => 'Filling hit counts (9/11)',
302 ),
303 'staged_build_s10_index_sort' => array(
304 'query_label' => 'ALTER TABLE wp_abj404_view_build ADD INDEX (sort indexes)',
305 'what_happening' => 'Adding read-side sort indexes (10/11)',
306 ),
307 'staged_build_s11_swap' => array(
308 'query_label' => 'RENAME TABLE wp_abj404_view_build TO wp_abj404_view_done',
309 'what_happening' => 'Atomic table swap (11/11)',
310 ),
311 );
312 if (array_key_exists($stage, $map)) {
313 return $map[$stage];
314 }
315 // Sub-stage with a free-form ":detail" suffix (e.g. the batched insert
316 // emits 'staged_build_s2_insert:batch 4/12'). Strip the detail to find
317 // the base label, then append the detail to what_happening so the GUI
318 // shows "Bulk-loading redirects into build buffer (2/11) — batch 4/12".
319 $colonPos = is_string($stage) ? strpos((string)$stage, ':') : false;
320 if ($colonPos !== false) {
321 $base = substr((string)$stage, 0, $colonPos);
322 $detail = trim(substr((string)$stage, $colonPos + 1));
323 if (array_key_exists($base, $map)) {
324 $entry = $map[$base];
325 if ($detail !== '') {
326 $entry['what_happening'] = $entry['what_happening'] . ' — ' . $detail;
327 }
328 return $entry;
329 }
330 }
331 return array(
332 'query_label' => (string)$stage,
333 'what_happening' => 'Running AJAX stage ' . (string)$stage,
334 );
335 }
336
337 /**
338 * Public version of setStage() that reads the AJAX requestId from the
339 * global context rather than requiring a `&$context` reference. Used by
340 * code paths (e.g. the staged view-build pipeline) that run beneath
341 * DataAccess and don't have $context threaded through.
342 *
343 * Best-effort: if no AJAX context exists (background cron, CLI), this is
344 * a no-op — no transient is written and no global is mutated.
345 *
346 * @param string $stage Stage label. May be a known key in
347 * getStageDiagnostics(), or `<key>:<detail>` where
348 * detail is appended to what_happening for mid-stage
349 * progress messages (e.g. 'staged_build_s2_insert:batch 4/12').
350 * @return void
351 */
352 public static function markInflightStage($stage) {
353 if (!isset($GLOBALS['abj404_ajax_context']) || !is_array($GLOBALS['abj404_ajax_context'])) {
354 return;
355 }
356 $rawContext = $GLOBALS['abj404_ajax_context'];
357 $context = array();
358 foreach ($rawContext as $key => $value) {
359 if (is_string($key)) {
360 $context[$key] = $value;
361 }
362 }
363 self::setStage($context, (string)$stage);
364 $GLOBALS['abj404_ajax_context'] = $context;
365 }
366
367 /**
368 * @param int $type
369 * @return bool
370 */
371 public static function isFatalErrorType($type) {
372 $fatalTypes = array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR);
373 return in_array($type, $fatalTypes, true);
374 }
375
376 /**
377 * @param string $message
378 * @param array<string, mixed>|null $details
379 * @param bool $isPluginAdmin
380 * @return array<string, mixed>
381 */
382 public static function buildAjaxErrorResponse($message, $details, $isPluginAdmin) {
383 $data = array(
384 'message' => $message,
385 );
386 if ($isPluginAdmin && $details !== null) {
387 $data['details'] = $details;
388 }
389 return array(
390 'success' => false,
391 'data' => $data,
392 );
393 }
394
395 /**
396 * @param mixed $payload
397 * @param int $httpStatus
398 * @return void
399 */
400 public static function sendJsonResponseAndExit($payload, $httpStatus = 200) {
401 if (!headers_sent()) {
402 // Marker headers help support quickly identify that this response came from our AJAX endpoint.
403 // These are safe to expose (no sensitive values).
404 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
405 $ctx = $GLOBALS['abj404_ajax_context'];
406 if (array_key_exists('action', $ctx) && is_string($ctx['action'])) {
407 header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $ctx['action']));
408 }
409 if (array_key_exists('subpage', $ctx) && is_string($ctx['subpage']) && $ctx['subpage'] !== '') {
410 header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $ctx['subpage']));
411 }
412 if (array_key_exists('requestId', $ctx) && is_string($ctx['requestId']) && $ctx['requestId'] !== '') {
413 header('X-ABJ404-Request-Id: ' . preg_replace('/[^a-zA-Z0-9]/', '', $ctx['requestId']));
414 }
415 }
416 header('Content-type: application/json; charset=UTF-8');
417 if (function_exists('status_header')) {
418 status_header($httpStatus);
419 } else if (function_exists('http_response_code')) {
420 http_response_code($httpStatus);
421 }
422 }
423 echo json_encode($payload);
424
425 // Test hook: tests register `abj404_should_exit` returning false to skip exit.
426 if (!apply_filters('abj404_should_exit', true, array('source' => 'viewUpdater_emitJson'))) {
427 return;
428 }
429
430 // Flush the response to the web server immediately so shutdown hooks
431 // (e.g. hits table rebuild) don't block the HTTP connection. Without this,
432 // reverse proxies like Cloudflare may time out (HTTP 524) if a shutdown
433 // hook runs a slow query, because the HTTP response isn't delivered until
434 // PHP exits.
435 if (function_exists('ob_end_flush')) {
436 while (ob_get_level() > 0) {
437 ob_end_flush();
438 }
439 }
440 if (function_exists('flush')) {
441 flush();
442 }
443 if (function_exists('fastcgi_finish_request')) {
444 fastcgi_finish_request();
445 }
446
447 exit;
448 }
449
450 /**
451 * @param object|null $abj404view
452 * @return object
453 */
454 private static function resolveViewInstance(&$abj404view) {
455 if (is_object($abj404view)) {
456 return $abj404view;
457 }
458 if (function_exists('abj_service')) {
459 $resolved = abj_service('view');
460 if (is_object($resolved)) {
461 $abj404view = $resolved;
462 return $abj404view;
463 }
464 }
465 throw new Exception('ABJ404 view service not initialized (abj404view is null).');
466 }
467
468 // safeJsonEncode / redactSqlShape / safeLogAjaxFailure /
469 // extractViewQueryDiagnostics live on ABJ_404_Solution_AjaxFailureLoggingTrait
470 // (see includes/ajax/AjaxFailureLoggingTrait.php). self::method() calls
471 // resolve through the trait composition unchanged.
472
473 /**
474 * @param array<string, mixed> $context
475 * @return array<string, mixed>
476 */
477 private static function startAjaxDebugContext($context) {
478 if (!is_array($context)) {
479 $context = array();
480 }
481
482 // Keep minimal state in a global so the global shutdown handler can act on it.
483 // Mark that this context was created internally by this handler (not user input).
484 $context['abj404_context_source'] = 'ViewUpdater::getPaginationLinks';
485 $context['ajax_expected_json'] = true;
486 $context['response_sent'] = false;
487 $context['ob_level_before'] = ob_get_level();
488 // Client-supplied request id for in-flight stage diagnostics (see setStage()).
489 // The browser generates this so it has a key to look up the stage even when
490 // a pure timeout means no response/header ever arrived.
491 $context['requestId'] = self::readClientRequestId();
492
493 // Prevent WordPress's "critical error" HTML page from masking details for AJAX calls.
494 if (!headers_sent()) {
495 // Marker headers help support quickly identify that this response came from our AJAX endpoint.
496 // These are safe to expose (no sensitive values).
497 if (array_key_exists('action', $context) && is_string($context['action'])) {
498 header('X-ABJ404-Ajax: ' . preg_replace('/[\r\n]+/', '', $context['action']));
499 }
500 if (array_key_exists('subpage', $context) && is_string($context['subpage']) && $context['subpage'] !== '') {
501 header('X-ABJ404-Subpage: ' . preg_replace('/[\r\n]+/', '', $context['subpage']));
502 }
503 if ($context['requestId'] !== '') {
504 header('X-ABJ404-Request-Id: ' . $context['requestId']);
505 }
506 @ini_set('display_errors', '0');
507 }
508 if (apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'viewUpdater_startAjaxDebugContext'))) {
509 @ob_start();
510 }
511
512 $GLOBALS['abj404_ajax_context'] = $context;
513 return $context;
514 }
515
516 /** @return void */
517 private static function markAjaxResponseSent() {
518 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
519 $GLOBALS['abj404_ajax_context']['response_sent'] = true;
520 }
521 }
522
523 /** @return string */
524 private static function getAndClearAjaxBufferedOutput() {
525 if (!apply_filters('abj404_should_manage_output_buffer', true, array('source' => 'viewUpdater_getAndClearAjaxBufferedOutput'))) {
526 return '';
527 }
528
529 $out = '';
530 if (ob_get_level() > 0) {
531 $out = (string)ob_get_contents();
532 }
533
534 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
535 $minLevel = array_key_exists('ob_level_before', $GLOBALS['abj404_ajax_context'])
536 ? intval($GLOBALS['abj404_ajax_context']['ob_level_before']) : 0;
537 while (ob_get_level() > $minLevel) {
538 @ob_end_clean();
539 }
540 } else {
541 while (ob_get_level() > 0) {
542 @ob_end_clean();
543 }
544 }
545
546 return $out;
547 }
548
549 /** @return void */
550 function getPaginationLinks() {
551 $abj404dao = abj_service('data_access');
552 $abj404logic = abj_service('plugin_logic');
553 global $abj404view;
554
555 $rowsPerPage = absint($abj404dao->getPostOrGetSanitize('rowsPerPage'));
556 $subpage = $abj404dao->getPostOrGetSanitize('subpage');
557 $nonce = $abj404dao->getPostOrGetSanitize('nonce');
558 $page = $abj404dao->getPostOrGetSanitize('page', '');
559 $filterText = $abj404dao->getPostOrGetSanitize('filterText', '');
560 $filter = $abj404dao->getPostOrGetSanitize('filter', '');
561 $detectOnly = ((string)$abj404dao->getPostOrGetSanitize('detectOnly', '0') === '1');
562 $cacheModeRaw = (string)$abj404dao->getPostOrGetSanitize('cacheMode', 'normal');
563 $cacheMode = in_array($cacheModeRaw, array('normal', 'cache_or_pending', 'refresh_cache'), true)
564 ? $cacheModeRaw : 'normal';
565 $currentSignature = strtolower(trim((string)$abj404dao->getPostOrGetSanitize('currentSignature', '')));
566 if (strlen($currentSignature) > 128) {
567 $currentSignature = substr($currentSignature, 0, 128);
568 }
569
570 $isPluginAdmin = false;
571 $context = array(
572 'action' => 'ajaxUpdatePaginationLinks',
573 'page' => $page,
574 'subpage' => $subpage,
575 'rowsPerPage' => $rowsPerPage,
576 'filterText_length' => strlen((string)$filterText),
577 'filter' => $filter,
578 'detectOnly' => $detectOnly ? 1 : 0,
579 'cacheMode' => $cacheMode,
580 'currentSignature_length' => strlen($currentSignature),
581 'request_uri' => array_key_exists('REQUEST_URI', $_SERVER) ? $_SERVER['REQUEST_URI'] : '',
582 'user_id' => function_exists('get_current_user_id') ? get_current_user_id() : 0,
583 );
584 $context = self::startAjaxDebugContext($context);
585
586 try {
587 // Verify nonce for CSRF protection
588 if (!wp_verify_nonce($nonce, 'abj404_updatePaginationLink')) {
589 self::safeLogAjaxFailure('AJAX invalid nonce in ajaxUpdatePaginationLinks.', $context);
590 self::markAjaxResponseSent();
591 $payload = self::buildAjaxErrorResponse('Invalid security token', null, false);
592 self::sendJsonResponseAndExit($payload, 403);
593 return;
594 }
595
596 // Verify user has appropriate capabilities (respects plugin admin users)
597 $abj404logic = abj_service('plugin_logic');
598 $isPluginAdmin = $abj404logic->userIsPluginAdmin();
599 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
600 $GLOBALS['abj404_ajax_context']['is_plugin_admin'] = $isPluginAdmin;
601 }
602 if (!$isPluginAdmin) {
603 self::safeLogAjaxFailure('AJAX unauthorized in ajaxUpdatePaginationLinks.', $context);
604 self::markAjaxResponseSent();
605 $payload = self::buildAjaxErrorResponse('Unauthorized', null, false);
606 self::sendJsonResponseAndExit($payload, 403);
607 return;
608 }
609
610 // Rate limiting to prevent abuse.
611 // This endpoint is hit by first-paint table loads, filter typing, pagination, and
612 // background detect-only checks; 100/min can throttle normal admin usage and leave
613 // tables stuck on "Loading…" under active workflows.
614 // Keep the protection, but use high ceilings for authenticated plugin-admin traffic.
615 // Parallel admin workflows can legitimately burst well above a few hundred requests/min.
616 $maxRequestsPerMinute = $detectOnly ? 3000 : 1500;
617 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('update_pagination', $maxRequestsPerMinute, 60)) {
618 self::safeLogAjaxFailure('AJAX rate limit in ajaxUpdatePaginationLinks.', $context);
619 self::markAjaxResponseSent();
620 $payload = self::buildAjaxErrorResponse('Rate limit exceeded. Please try again later.', null, false);
621 self::sendJsonResponseAndExit($payload, 429);
622 return;
623 }
624
625 // Update the perpage option (but only if provided).
626 // Some environments may omit rowsPerPage on Enter key events; avoid unnecessary option writes.
627 if ($rowsPerPage > 0) {
628 $abj404logic->updatePerPageOption($rowsPerPage);
629 }
630
631 /** @var ABJ_404_Solution_View $view */
632 $view = self::resolveViewInstance($abj404view);
633
634 // View-build gate: never let an AJAX fetch trigger an inline staged
635 // build. If the precomputed view_done table is not serveable
636 // (missing or invalidated by a recent redirect edit), respond
637 // immediately with `viewBuildPending` and let the JS poller hit
638 // ajaxAdvanceViewBuild repeatedly to advance the build one tick
639 // per call. No HTTP 500 path from build pressure can happen here.
640 if (($subpage === 'abj404_redirects' || $subpage === 'abj404_captured')
641 && !$detectOnly
642 && is_object($abj404dao)
643 && method_exists($abj404dao, 'viewDoneIsServeable')
644 && !$abj404dao->viewDoneIsServeable()) {
645 $stage = ($subpage === 'abj404_captured') ? 'table_captured' : 'table_redirects';
646 self::setStage($context, $stage);
647 $progress = method_exists($abj404dao, 'getViewBuildProgress')
648 ? $abj404dao->getViewBuildProgress()
649 : array('status' => 'pending', 'stage' => 0, 'of' => 11,
650 'build_started' => 0, 'progress_text' => 'not yet started');
651 self::markAjaxResponseSent();
652 self::getAndClearAjaxBufferedOutput();
653 self::sendJsonResponseAndExit(array(
654 'viewBuildPending' => true,
655 'cacheMode' => $cacheMode,
656 'subpage' => $subpage,
657 'progress' => $progress,
658 'message' => __('Preparing the redirects view table. Please wait.', '404-solution'),
659 ), 200);
660 return;
661 }
662
663 if ($cacheMode === 'cache_or_pending'
664 && !$detectOnly
665 && ($subpage === 'abj404_redirects' || $subpage === 'abj404_captured')
666 && is_object($abj404dao)
667 && method_exists($abj404dao, 'viewTableSnapshotAvailable')) {
668 $stage = ($subpage === 'abj404_captured') ? 'table_captured' : 'table_redirects';
669 self::setStage($context, $stage);
670 $tableOptions = $abj404logic->getTableOptions($subpage);
671 if (!$abj404dao->viewTableSnapshotAvailable($subpage, $tableOptions)) {
672 self::markAjaxResponseSent();
673 self::getAndClearAjaxBufferedOutput();
674 self::sendJsonResponseAndExit(array(
675 'cachePending' => true,
676 'cacheMode' => $cacheMode,
677 'subpage' => $subpage,
678 'message' => __('Preparing table data in the background.', '404-solution'),
679 ), 200);
680 return;
681 }
682 }
683
684 $data = array();
685 if ($subpage == 'abj404_redirects') {
686 self::setStage($context, 'table_redirects');
687 $data['table'] = $view->getAdminRedirectsPageTable($subpage);
688
689 // Include tab counts so the page shell can render instantly with
690 // placeholders and fill them in. The slower health-bar query
691 // (getHighImpactCapturedCount, see refreshHealthBar()) is fetched
692 // in a separate AJAX call so it never blocks first paint of the table.
693 self::setStage($context, 'redirect_status_counts');
694 $statusCounts = $abj404dao->getRedirectStatusCounts();
695 // Tab counts keyed by filter value for JS tab updates.
696 $data['tabCounts'] = array(
697 '0' => $statusCounts['all'] ?? 0,
698 (string)ABJ404_STATUS_MANUAL => $statusCounts['manual'] ?? 0,
699 (string)ABJ404_STATUS_AUTO => $statusCounts['auto'] ?? 0,
700 (string)ABJ404_TRASH_FILTER => $statusCounts['trash'] ?? 0,
701 );
702
703 } else if ($subpage == 'abj404_captured') {
704 self::setStage($context, 'table_captured');
705 $data['table'] = $view->getCapturedURLSPageTable($subpage);
706
707 // Include tab counts so the page shell can render instantly.
708 self::setStage($context, 'captured_status_counts');
709 $statusCounts = $abj404dao->getCapturedStatusCounts();
710 $data['statusCounts'] = $statusCounts;
711 // Tab counts keyed by filter value for JS tab updates.
712 // Includes the "handled" composite count for simple mode.
713 $data['tabCounts'] = array(
714 '0' => $statusCounts['all'] ?? 0,
715 (string)ABJ404_STATUS_CAPTURED => $statusCounts['captured'] ?? 0,
716 (string)ABJ404_STATUS_IGNORED => $statusCounts['ignored'] ?? 0,
717 (string)ABJ404_STATUS_LATER => $statusCounts['later'] ?? 0,
718 (string)ABJ404_TRASH_FILTER => $statusCounts['trash'] ?? 0,
719 (string)ABJ404_HANDLED_FILTER => ($statusCounts['ignored'] ?? 0) + ($statusCounts['later'] ?? 0) + ($statusCounts['trash'] ?? 0),
720 );
721
722 } else if ($subpage == 'abj404_logs') {
723 self::setStage($context, 'table_logs');
724 $data['table'] = $view->getAdminLogsPageTable($subpage);
725
726 } else {
727 $data['table'] = 'Error: Unexpected subpage requested.';
728 }
729
730 $tableSignature = '';
731 if (is_object($view) && method_exists($view, 'getCurrentTableDataSignature')) {
732 $tableSignature = (string)$view->getCurrentTableDataSignature($subpage);
733 }
734 $data['tableSignature'] = $tableSignature;
735 if ($detectOnly) {
736 $signaturesMatch = false;
737 if ($currentSignature !== '' && $tableSignature !== '') {
738 if (function_exists('hash_equals')) {
739 $signaturesMatch = hash_equals($currentSignature, $tableSignature);
740 } else {
741 $signaturesMatch = ($currentSignature === $tableSignature);
742 }
743 }
744 $data['hasUpdate'] = (
745 $currentSignature !== '' &&
746 $tableSignature !== '' &&
747 !$signaturesMatch
748 );
749 }
750
751 self::setStage($context, 'paginationLinksTop');
752 $data['paginationLinksTop'] = $view->getPaginationLinks($subpage);
753 self::setStage($context, 'paginationLinksBottom');
754 $data['paginationLinksBottom'] = $view->getPaginationLinks($subpage, false);
755
756 self::markAjaxResponseSent();
757 self::getAndClearAjaxBufferedOutput();
758 self::sendJsonResponseAndExit($data, 200);
759 return;
760
761 } catch (Throwable $e) {
762 // Race recovery: viewDoneIsServeable() can race with invalidateViewDone();
763 // surface the pending shape the JS poller already handles, never a 500.
764 $pending = ABJ_404_Solution_ViewBuildPendingResponseBuilder::find($e);
765 if ($pending !== null) {
766 self::markAjaxResponseSent();
767 self::getAndClearAjaxBufferedOutput();
768 self::sendJsonResponseAndExit(
769 ABJ_404_Solution_ViewBuildPendingResponseBuilder::fetchResponse($abj404dao, $subpage, $cacheMode, $pending),
770 200
771 );
772 return;
773 }
774 // Determine admin status for diagnostics (never shown to non-admins).
775 // If PluginLogic is broken/throws, fall back to WordPress capability checks so real admins can still see details.
776 if (!$isPluginAdmin) {
777 $abj404logic = abj_service('plugin_logic');
778 if (is_object($abj404logic) && method_exists($abj404logic, 'userIsPluginAdmin')) {
779 try {
780 $isPluginAdmin = (bool)$abj404logic->userIsPluginAdmin();
781 } catch (Throwable $ignored) {
782 $isPluginAdmin = false;
783 }
784 }
785 if (!$isPluginAdmin) {
786 // Best-effort fallback: treat WordPress administrators as plugin admins for debugging
787 // if PluginLogic is broken. Avoid current_user_can() to keep delegated admin semantics
788 // centralized in PluginLogic.
789 if (function_exists('wp_get_current_user')) {
790 $user = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user());
791 if ($user !== null) {
792 $isPluginAdmin = $user->isAdministrator();
793 }
794 }
795 if (!$isPluginAdmin && function_exists('is_super_admin') && is_super_admin()) {
796 $isPluginAdmin = true;
797 }
798 }
799 if (isset($GLOBALS['abj404_ajax_context']) && is_array($GLOBALS['abj404_ajax_context'])) {
800 $GLOBALS['abj404_ajax_context']['is_plugin_admin'] = $isPluginAdmin;
801 }
802 }
803
804 $details = array(
805 'exception' => array(
806 'message' => $e->getMessage(),
807 'file' => $e->getFile(),
808 'line' => $e->getLine(),
809 'trace' => $e->getTraceAsString(),
810 ),
811 'context' => $context,
812 );
813 if (isset($GLOBALS['wpdb']) && is_object($GLOBALS['wpdb'])) {
814 $lastQuery = $GLOBALS['wpdb']->last_query ?? '';
815 $details['wpdb'] = array(
816 'last_error' => $GLOBALS['wpdb']->last_error ?? '',
817 'last_query_redacted' => self::redactSqlShape($lastQuery),
818 'last_query_length' => is_string($lastQuery) ? strlen($lastQuery) : 0,
819 );
820 }
821 $viewQueryDiagnostics = self::extractViewQueryDiagnostics($e);
822 if ($viewQueryDiagnostics !== null) {
823 $details['view_query_diagnostics'] = $viewQueryDiagnostics;
824 }
825
826 // Always log to the plugin debug file, regardless of admin status.
827 self::safeLogAjaxFailure('AJAX exception in ajaxUpdatePaginationLinks.', $details, $e);
828 $capturedOutput = self::getAndClearAjaxBufferedOutput();
829 if ($capturedOutput !== '') {
830 $details['buffered_output'] = substr($capturedOutput, 0, 8000);
831 }
832
833 self::markAjaxResponseSent();
834 $payload = self::buildAjaxErrorResponse(
835 'Server error while updating the table.',
836 $details,
837 $isPluginAdmin
838 );
839 self::sendJsonResponseAndExit($payload, 500);
840 return;
841 }
842 }
843
844 /** @return void */
845 function warmTableCache() {
846 $abj404dao = abj_service('data_access');
847 $abj404logic = abj_service('plugin_logic');
848
849 $rowsPerPage = absint($abj404dao->getPostOrGetSanitize('rowsPerPage'));
850 $subpage = $abj404dao->getPostOrGetSanitize('subpage');
851 $nonce = $abj404dao->getPostOrGetSanitize('nonce');
852 $page = $abj404dao->getPostOrGetSanitize('page', '');
853 $filterText = $abj404dao->getPostOrGetSanitize('filterText', '');
854 $filter = $abj404dao->getPostOrGetSanitize('filter', '');
855
856 $isPluginAdmin = false;
857 $context = array(
858 'action' => 'ajaxWarmTableCache',
859 'page' => $page,
860 'subpage' => $subpage,
861 'rowsPerPage' => $rowsPerPage,
862 'filterText_length' => strlen((string)$filterText),
863 'filter' => $filter,
864 'request_uri' => array_key_exists('REQUEST_URI', $_SERVER) ? $_SERVER['REQUEST_URI'] : '',
865 'user_id' => function_exists('get_current_user_id') ? get_current_user_id() : 0,
866 );
867 $context = self::startAjaxDebugContext($context);
868
869 try {
870 if (!wp_verify_nonce($nonce, 'abj404_updatePaginationLink')) {
871 self::safeLogAjaxFailure('AJAX invalid nonce in ajaxWarmTableCache.', $context);
872 self::markAjaxResponseSent();
873 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Invalid security token', null, false), 403);
874 return;
875 }
876
877 $isPluginAdmin = $abj404logic->userIsPluginAdmin();
878 if (!$isPluginAdmin) {
879 self::safeLogAjaxFailure('AJAX unauthorized in ajaxWarmTableCache.', $context);
880 self::markAjaxResponseSent();
881 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Unauthorized', null, false), 403);
882 return;
883 }
884
885 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('warm_table_cache', 1500, 60)) {
886 self::safeLogAjaxFailure('AJAX rate limit in ajaxWarmTableCache.', $context);
887 self::markAjaxResponseSent();
888 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Rate limit exceeded. Please try again later.', null, false), 429);
889 return;
890 }
891
892 if ($rowsPerPage > 0) {
893 $abj404logic->updatePerPageOption($rowsPerPage);
894 }
895
896 if ($subpage !== 'abj404_redirects' && $subpage !== 'abj404_captured') {
897 self::markAjaxResponseSent();
898 self::getAndClearAjaxBufferedOutput();
899 self::sendJsonResponseAndExit(array(
900 'status' => 'ready',
901 'ready' => true,
902 'uncached' => true,
903 'stage' => 'rows',
904 'stageNumber' => 1,
905 'queryLabel' => 'getRedirectsForView',
906 ), 200);
907 return;
908 }
909
910 // Same view-build gate as the fetch endpoint: warming the snapshot
911 // cache calls getRedirectsForView, which will inline-build the
912 // staged view_done if missing. When view_done is not serveable,
913 // the JS poller must advance the build via ajaxAdvanceViewBuild
914 // before the snapshot warm can start. Returning ready=false here
915 // keeps the placeholder hydration loop running until then.
916 if (is_object($abj404dao) && method_exists($abj404dao, 'viewDoneIsServeable')
917 && !$abj404dao->viewDoneIsServeable()) {
918 $progress = method_exists($abj404dao, 'getViewBuildProgress')
919 ? $abj404dao->getViewBuildProgress()
920 : array('status' => 'pending', 'stage' => 0, 'of' => 11,
921 'build_started' => 0, 'progress_text' => 'not yet started');
922 self::markAjaxResponseSent();
923 self::getAndClearAjaxBufferedOutput();
924 self::sendJsonResponseAndExit(array(
925 'status' => 'pending',
926 'ready' => false,
927 'viewBuildPending' => true,
928 'stage' => 'rows',
929 'stageNumber' => 1,
930 'queryLabel' => 'getRedirectsForView',
931 'progress' => $progress,
932 ), 200);
933 return;
934 }
935
936 $tableOptions = $abj404logic->getTableOptions($subpage);
937 $stage = 'table_cache_rows';
938 if (is_object($abj404dao) && method_exists($abj404dao, 'viewRowsSnapshotAvailable')
939 && $abj404dao->viewRowsSnapshotAvailable($subpage, $tableOptions)) {
940 $stage = 'table_cache_count';
941 }
942 self::setStage($context, $stage);
943 $warmup = $abj404dao->warmViewTableSnapshotStage($subpage, $tableOptions);
944
945 self::markAjaxResponseSent();
946 self::getAndClearAjaxBufferedOutput();
947 self::sendJsonResponseAndExit($warmup, 200);
948 return;
949 } catch (Throwable $e) {
950 // Race recovery: same defense as getPaginationLinks. The warm
951 // path uses a different response shape because the JS placeholder
952 // hydration consumes ready=false directly.
953 $pending = ABJ_404_Solution_ViewBuildPendingResponseBuilder::find($e);
954 if ($pending !== null) {
955 self::markAjaxResponseSent();
956 self::getAndClearAjaxBufferedOutput();
957 self::sendJsonResponseAndExit(
958 ABJ_404_Solution_ViewBuildPendingResponseBuilder::warmResponse($abj404dao, $pending),
959 200
960 );
961 return;
962 }
963 if (!$isPluginAdmin) {
964 $abj404logic = abj_service('plugin_logic');
965 if (is_object($abj404logic) && method_exists($abj404logic, 'userIsPluginAdmin')) {
966 try {
967 $isPluginAdmin = (bool)$abj404logic->userIsPluginAdmin();
968 } catch (Throwable $ignored) {
969 $isPluginAdmin = false;
970 }
971 }
972 }
973
974 $details = array(
975 'exception' => array(
976 'message' => $e->getMessage(),
977 'file' => $e->getFile(),
978 'line' => $e->getLine(),
979 'trace' => $e->getTraceAsString(),
980 ),
981 'context' => $context,
982 );
983 $viewQueryDiagnostics = self::extractViewQueryDiagnostics($e);
984 if ($viewQueryDiagnostics !== null) {
985 $details['view_query_diagnostics'] = $viewQueryDiagnostics;
986 }
987 self::safeLogAjaxFailure('AJAX exception in ajaxWarmTableCache.', $details, $e);
988 $capturedOutput = self::getAndClearAjaxBufferedOutput();
989 if ($capturedOutput !== '') {
990 $details['buffered_output'] = substr($capturedOutput, 0, 8000);
991 }
992
993 self::markAjaxResponseSent();
994 self::sendJsonResponseAndExit(
995 self::buildAjaxErrorResponse('Server error while preparing table data.', $details, $isPluginAdmin),
996 500
997 );
998 return;
999 }
1000 }
1001
1002 /** @return void */
1003 function refreshStatsDashboard() {
1004 $abj404dao = abj_service('data_access');
1005 $abj404logic = abj_service('plugin_logic');
1006
1007 $nonce = $abj404dao->getPostOrGetSanitize('nonce');
1008 $page = $abj404dao->getPostOrGetSanitize('page', '');
1009 $subpage = $abj404dao->getPostOrGetSanitize('subpage', '');
1010 $currentHash = $abj404dao->getPostOrGetSanitize('currentHash', '');
1011
1012 $isPluginAdmin = false;
1013 $context = array(
1014 'action' => 'ajaxRefreshStatsDashboard',
1015 'page' => $page,
1016 'subpage' => $subpage,
1017 'request_uri' => array_key_exists('REQUEST_URI', $_SERVER) ? $_SERVER['REQUEST_URI'] : '',
1018 'user_id' => function_exists('get_current_user_id') ? get_current_user_id() : 0,
1019 );
1020 $context = self::startAjaxDebugContext($context);
1021
1022 try {
1023 if (!wp_verify_nonce($nonce, 'abj404_refreshStatsDashboard')) {
1024 self::safeLogAjaxFailure('AJAX invalid nonce in ajaxRefreshStatsDashboard.', $context);
1025 self::markAjaxResponseSent();
1026 $payload = self::buildAjaxErrorResponse('Invalid security token', null, false);
1027 self::sendJsonResponseAndExit($payload, 403);
1028 return;
1029 }
1030
1031 $isPluginAdmin = $abj404logic->userIsPluginAdmin();
1032 if (!$isPluginAdmin) {
1033 self::safeLogAjaxFailure('AJAX unauthorized in ajaxRefreshStatsDashboard.', $context);
1034 self::markAjaxResponseSent();
1035 $payload = self::buildAjaxErrorResponse('Unauthorized', null, false);
1036 self::sendJsonResponseAndExit($payload, 403);
1037 return;
1038 }
1039
1040 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('refresh_stats_dashboard', 30, 60)) {
1041 self::safeLogAjaxFailure('AJAX rate limit in ajaxRefreshStatsDashboard.', $context);
1042 self::markAjaxResponseSent();
1043 $payload = self::buildAjaxErrorResponse('Rate limit exceeded. Please try again later.', null, false);
1044 self::sendJsonResponseAndExit($payload, 429);
1045 return;
1046 }
1047
1048 $snapshot = $abj404dao->refreshStatsDashboardSnapshot(false);
1049 $newHash = $snapshot['hash'];
1050 $hasUpdate = ($newHash !== '' && ($currentHash === '' || $newHash !== $currentHash));
1051
1052 $response = array(
1053 'hasUpdate' => $hasUpdate,
1054 'hash' => $newHash,
1055 'refreshedAt' => intval($snapshot['refreshed_at']),
1056 );
1057
1058 self::markAjaxResponseSent();
1059 self::getAndClearAjaxBufferedOutput();
1060 self::sendJsonResponseAndExit($response, 200);
1061 return;
1062
1063 } catch (Throwable $e) {
1064 if (!$isPluginAdmin) {
1065 $abj404logic = abj_service('plugin_logic');
1066 if (is_object($abj404logic) && method_exists($abj404logic, 'userIsPluginAdmin')) {
1067 try {
1068 $isPluginAdmin = (bool)$abj404logic->userIsPluginAdmin();
1069 } catch (Throwable $ignored) {
1070 $isPluginAdmin = false;
1071 }
1072 }
1073 }
1074
1075 $details = array(
1076 'exception' => array(
1077 'message' => $e->getMessage(),
1078 'file' => $e->getFile(),
1079 'line' => $e->getLine(),
1080 'trace' => $e->getTraceAsString(),
1081 ),
1082 'context' => $context,
1083 );
1084 self::safeLogAjaxFailure('AJAX exception in ajaxRefreshStatsDashboard.', $details, $e);
1085 $capturedOutput = self::getAndClearAjaxBufferedOutput();
1086 if ($capturedOutput !== '') {
1087 $details['buffered_output'] = substr($capturedOutput, 0, 8000);
1088 }
1089
1090 self::markAjaxResponseSent();
1091 $payload = self::buildAjaxErrorResponse(
1092 'Server error while refreshing stats.',
1093 $details,
1094 $isPluginAdmin
1095 );
1096 self::sendJsonResponseAndExit($payload, 500);
1097 return;
1098 }
1099 }
1100
1101 /**
1102 * Returns the data needed to render the redirects-page health bar:
1103 * the high-impact captured-URL count and the redirect status counts
1104 * (so the JS can compute "active = all - trash" and build the View link).
1105 *
1106 * Decoupled from ajaxUpdatePaginationLinks because getHighImpactCapturedCount()
1107 * can run for tens of seconds on a cold cache against multi-million-row logs;
1108 * letting it block the table response leaves the page stuck on "Loading…".
1109 *
1110 * @return void
1111 */
1112 function refreshHealthBar() {
1113 $abj404dao = abj_service('data_access');
1114 $abj404logic = abj_service('plugin_logic');
1115
1116 $nonce = $abj404dao->getPostOrGetSanitize('nonce');
1117 $page = $abj404dao->getPostOrGetSanitize('page', '');
1118 $subpage = $abj404dao->getPostOrGetSanitize('subpage', '');
1119
1120 $isPluginAdmin = false;
1121 $context = array(
1122 'action' => 'ajaxRefreshHealthBar',
1123 'page' => $page,
1124 'subpage' => $subpage,
1125 'request_uri' => array_key_exists('REQUEST_URI', $_SERVER) ? $_SERVER['REQUEST_URI'] : '',
1126 'user_id' => function_exists('get_current_user_id') ? get_current_user_id() : 0,
1127 );
1128 $context = self::startAjaxDebugContext($context);
1129
1130 try {
1131 if (!wp_verify_nonce($nonce, 'abj404_refreshHealthBar')) {
1132 self::safeLogAjaxFailure('AJAX invalid nonce in ajaxRefreshHealthBar.', $context);
1133 self::markAjaxResponseSent();
1134 $payload = self::buildAjaxErrorResponse('Invalid security token', null, false);
1135 self::sendJsonResponseAndExit($payload, 403);
1136 return;
1137 }
1138
1139 $isPluginAdmin = $abj404logic->userIsPluginAdmin();
1140 if (!$isPluginAdmin) {
1141 self::safeLogAjaxFailure('AJAX unauthorized in ajaxRefreshHealthBar.', $context);
1142 self::markAjaxResponseSent();
1143 $payload = self::buildAjaxErrorResponse('Unauthorized', null, false);
1144 self::sendJsonResponseAndExit($payload, 403);
1145 return;
1146 }
1147
1148 // Match the pagination AJAX rate limit ceiling — admin workflows
1149 // can re-trigger this on filter typing and tab switches.
1150 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('refresh_health_bar', 1500, 60)) {
1151 self::safeLogAjaxFailure('AJAX rate limit in ajaxRefreshHealthBar.', $context);
1152 self::markAjaxResponseSent();
1153 $payload = self::buildAjaxErrorResponse('Rate limit exceeded. Please try again later.', null, false);
1154 self::sendJsonResponseAndExit($payload, 429);
1155 return;
1156 }
1157
1158 self::setStage($context, 'redirect_status_counts');
1159 $statusCounts = $abj404dao->getRedirectStatusCounts();
1160 // Provide the captured filter constant so JS can build the "View" link.
1161 $statusCounts['_capturedFilter'] = ABJ404_STATUS_CAPTURED;
1162
1163 self::setStage($context, 'high_impact_count');
1164 $rollupAvailable = $abj404dao->logsHitsTableExists();
1165 if ($rollupAvailable) {
1166 $highImpactCapturedCount = (int)$abj404dao->getHighImpactCapturedCount();
1167 } else {
1168 $abj404dao->scheduleHitsTableRebuild();
1169 $highImpactCapturedCount = null;
1170 }
1171
1172 $response = array(
1173 'highImpactCapturedCount' => $highImpactCapturedCount,
1174 'rollupAvailable' => $rollupAvailable,
1175 'statusCounts' => $statusCounts,
1176 );
1177
1178 self::markAjaxResponseSent();
1179 self::getAndClearAjaxBufferedOutput();
1180 self::sendJsonResponseAndExit($response, 200);
1181 return;
1182
1183 } catch (Throwable $e) {
1184 if (!$isPluginAdmin) {
1185 $abj404logic = abj_service('plugin_logic');
1186 if (is_object($abj404logic) && method_exists($abj404logic, 'userIsPluginAdmin')) {
1187 try {
1188 $isPluginAdmin = (bool)$abj404logic->userIsPluginAdmin();
1189 } catch (Throwable $ignored) {
1190 $isPluginAdmin = false;
1191 }
1192 }
1193 }
1194
1195 $details = array(
1196 'exception' => array(
1197 'message' => $e->getMessage(),
1198 'file' => $e->getFile(),
1199 'line' => $e->getLine(),
1200 'trace' => $e->getTraceAsString(),
1201 ),
1202 'context' => $context,
1203 );
1204 self::safeLogAjaxFailure('AJAX exception in ajaxRefreshHealthBar.', $details, $e);
1205 $capturedOutput = self::getAndClearAjaxBufferedOutput();
1206 if ($capturedOutput !== '') {
1207 $details['buffered_output'] = substr($capturedOutput, 0, 8000);
1208 }
1209
1210 self::markAjaxResponseSent();
1211 $payload = self::buildAjaxErrorResponse(
1212 'Server error while refreshing health bar.',
1213 $details,
1214 $isPluginAdmin
1215 );
1216 self::sendJsonResponseAndExit($payload, 500);
1217 return;
1218 }
1219 }
1220
1221 /**
1222 * Look up the last in-flight stage stamped by `setStage()` for a given
1223 * client-supplied requestId. Used by the JS error handler when
1224 * `textStatus === 'timeout'` so the admin notice can name which phase the
1225 * server was in when the client gave up — diagnostics for pure client
1226 * timeouts where no response, header, or body ever arrives.
1227 *
1228 * Returns 200 with `{stage: '...'}` on success, `{stage: ''}` if the
1229 * transient has expired or the requestId is unknown. Reads (but does
1230 * not delete) the transient — letting it expire naturally avoids a race
1231 * if the original AJAX is still running.
1232 *
1233 * @return void
1234 */
1235 function fetchInflightStage() {
1236 $abj404dao = abj_service('data_access');
1237 $abj404logic = abj_service('plugin_logic');
1238
1239 $nonce = $abj404dao->getPostOrGetSanitize('nonce');
1240 $requestId = self::readClientRequestId();
1241
1242 try {
1243 if (!wp_verify_nonce($nonce, 'abj404_fetchInflightStage')) {
1244 self::sendJsonResponseAndExit(
1245 self::buildAjaxErrorResponse('Invalid security token', null, false),
1246 403
1247 );
1248 return;
1249 }
1250 if (!$abj404logic->userIsPluginAdmin()) {
1251 self::sendJsonResponseAndExit(
1252 self::buildAjaxErrorResponse('Unauthorized', null, false),
1253 403
1254 );
1255 return;
1256 }
1257 // Tight rate limit — this endpoint only fires from the JS timeout
1258 // handler. A real admin sees ~1 hit per stuck request.
1259 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('fetch_inflight_stage', 120, 60)) {
1260 self::sendJsonResponseAndExit(
1261 self::buildAjaxErrorResponse('Rate limit exceeded. Please try again later.', null, false),
1262 429
1263 );
1264 return;
1265 }
1266 if ($requestId === '') {
1267 self::sendJsonResponseAndExit(array('stage' => ''), 200);
1268 return;
1269 }
1270
1271 $stage = '';
1272 $queryLabel = '';
1273 $whatsHappening = '';
1274 $events = array();
1275 if (function_exists('get_transient')) {
1276 $value = get_transient('abj404_inflight_' . $requestId);
1277 if (is_array($value)) {
1278 $stage = isset($value['stage']) && is_string($value['stage']) ? $value['stage'] : '';
1279 $queryLabel = isset($value['query_label']) && is_string($value['query_label']) ? $value['query_label'] : '';
1280 $whatsHappening = isset($value['what_happening']) && is_string($value['what_happening']) ? $value['what_happening'] : '';
1281 $rawEvents = is_array($value['events'] ?? null) ? $value['events'] : array();
1282 foreach ($rawEvents as $rawEvent) {
1283 if (!is_array($rawEvent)) {
1284 continue;
1285 }
1286 $eventStage = isset($rawEvent['stage']) && is_string($rawEvent['stage']) ? $rawEvent['stage'] : '';
1287 if ($eventStage === '') {
1288 continue;
1289 }
1290 $events[] = array(
1291 'stage' => $eventStage,
1292 'queryLabel' => isset($rawEvent['query_label']) && is_string($rawEvent['query_label']) ? $rawEvent['query_label'] : '',
1293 'whatsHappening' => isset($rawEvent['what_happening']) && is_string($rawEvent['what_happening']) ? $rawEvent['what_happening'] : '',
1294 'timeMs' => isset($rawEvent['time_ms']) && is_scalar($rawEvent['time_ms']) ? intval($rawEvent['time_ms']) : 0,
1295 );
1296 }
1297 } else if (is_string($value)) {
1298 $stage = $value;
1299 $diagnostics = self::getStageDiagnostics($stage);
1300 $queryLabel = $diagnostics['query_label'];
1301 $whatsHappening = $diagnostics['what_happening'];
1302 }
1303 }
1304
1305 self::sendJsonResponseAndExit(array(
1306 'stage' => $stage,
1307 'queryLabel' => $queryLabel,
1308 'whatsHappening' => $whatsHappening,
1309 'events' => $events,
1310 ), 200);
1311 return;
1312
1313 } catch (Throwable $e) {
1314 // Diagnostics endpoint, never fail loudly. An admin-side notice
1315 // that says "stage: (lookup failed)" is a worse outcome than
1316 // "stage: (unknown)".
1317 self::sendJsonResponseAndExit(array('stage' => ''), 200);
1318 return;
1319 }
1320 }
1321
1322 /**
1323 * Bounded build-advance endpoint paired with the fetch-only path on
1324 * `getPaginationLinks` / `warmTableCache`. Each call runs at most one
1325 * resumable tick of the staged view_done build (10s/stage budget; yields
1326 * mid-stage on S2/S4/S5) and returns the current progress. The JS poller
1327 * fires this every ~1s after a fetch returns `viewBuildPending: true`.
1328 *
1329 * Idempotent: concurrent calls fail to acquire the build lock and just
1330 * return the current progress. Errors are returned as a 500 with the
1331 * standard error envelope so the JS poller can stop and surface a notice
1332 * instead of spinning forever.
1333 *
1334 * Reuses the `abj404_fetchInflightStage` nonce (already bound on every
1335 * admin page that can hit this endpoint) so no additional nonce plumbing
1336 * is needed.
1337 *
1338 * @return void
1339 */
1340 function advanceViewBuild() {
1341 $abj404dao = abj_service('data_access');
1342 $abj404logic = abj_service('plugin_logic');
1343
1344 $nonce = $abj404dao->getPostOrGetSanitize('nonce');
1345 $page = $abj404dao->getPostOrGetSanitize('page', '');
1346 $subpage = $abj404dao->getPostOrGetSanitize('subpage', '');
1347 $requestId = self::readClientRequestId();
1348 $forceViewRebuild = ((string)$abj404dao->getPostOrGetSanitize('forceViewRebuild', '0') === '1');
1349
1350 $isPluginAdmin = false;
1351 $context = array(
1352 'action' => 'ajaxAdvanceViewBuild',
1353 'page' => $page,
1354 'subpage' => $subpage,
1355 'requestId' => $requestId,
1356 'forceViewRebuild' => $forceViewRebuild ? 1 : 0,
1357 'request_uri' => array_key_exists('REQUEST_URI', $_SERVER) ? $_SERVER['REQUEST_URI'] : '',
1358 'user_id' => function_exists('get_current_user_id') ? get_current_user_id() : 0,
1359 );
1360 $context = self::startAjaxDebugContext($context);
1361
1362 try {
1363 if (!wp_verify_nonce($nonce, 'abj404_fetchInflightStage')) {
1364 self::safeLogAjaxFailure('AJAX invalid nonce in ajaxAdvanceViewBuild.', $context);
1365 self::markAjaxResponseSent();
1366 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Invalid security token', null, false), 403);
1367 return;
1368 }
1369
1370 $isPluginAdmin = $abj404logic->userIsPluginAdmin();
1371 if (!$isPluginAdmin) {
1372 self::safeLogAjaxFailure('AJAX unauthorized in ajaxAdvanceViewBuild.', $context);
1373 self::markAjaxResponseSent();
1374 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Unauthorized', null, false), 403);
1375 return;
1376 }
1377
1378 // The poller fires this once per second per admin tab while a
1379 // build is in progress. A single tab might burn ~120 calls in a
1380 // long resumable build; keep the ceiling well above that.
1381 if (ABJ_404_Solution_Ajax_Php::checkRateLimit('advance_view_build', 600, 60)) {
1382 self::safeLogAjaxFailure('AJAX rate limit in ajaxAdvanceViewBuild.', $context);
1383 self::markAjaxResponseSent();
1384 self::sendJsonResponseAndExit(self::buildAjaxErrorResponse('Rate limit exceeded. Please try again later.', null, false), 429);
1385 return;
1386 }
1387
1388 if (!is_object($abj404dao) || !method_exists($abj404dao, 'advanceViewBuildOnce')) {
1389 self::markAjaxResponseSent();
1390 self::getAndClearAjaxBufferedOutput();
1391 self::sendJsonResponseAndExit(array(
1392 'status' => 'unsupported',
1393 'progress' => array('status' => 'pending', 'stage' => 0, 'of' => 11,
1394 'build_started' => 0, 'progress_text' => 'unsupported'),
1395 ), 200);
1396 return;
1397 }
1398
1399 // The browser only sends forceViewRebuild=1 on the first advance
1400 // call of an ?abj404_force_view_rebuild=1 page-load. Pre-calling
1401 // forceRestartViewBuild() here (rather than in the fetch path)
1402 // keeps the rebuild owned by a single requestId so every staged
1403 // sub-stage shows up in the debug log. Non-blocking acquire (0s):
1404 // a sibling cron / tab holding the runner lock must not stall
1405 // this request; advanceViewBuildOnce(forceRebuild=true) below
1406 // waits up to 10s and runs equivalent drop/clear semantics
1407 // inside its own locked region. Migrated in Phase 3a step 4
1408 // (queue task t_260516_131200_429) from a direct
1409 // invalidateViewSnapshotCache() / invalidateViewDone() pre-call:
1410 // the runner-owned primitive (DataAccessTrait_ViewBuildForceRestart,
1411 // Phase 3a step 2 / c554) preserves the existing view_done
1412 // snapshot for parallel readers until the new S11 RENAME publishes
1413 // a fresh one, which is the intended force-rebuild contract.
1414 if ($forceViewRebuild && method_exists($abj404dao, 'forceRestartViewBuild')) {
1415 $abj404dao->forceRestartViewBuild(0);
1416 }
1417
1418 self::tryClaimForegroundViewBuildLease($abj404dao);
1419 // Pass forceRebuild down so advanceViewBuildOnce takes the lock
1420 // with a 30s timeout (waiting for any in-flight cron/sibling
1421 // build to finish), re-invalidates inside the locked region,
1422 // and runs the build under THIS request's AJAX context. That is
1423 // what makes every staged_build_s* sub-stage event reach the
1424 // browser's "AJAX Load Times / Debug Info" panel.
1425 $progress = $abj404dao->advanceViewBuildOnce($forceViewRebuild);
1426 $statusValue = is_array($progress) && isset($progress['status']) && is_string($progress['status'])
1427 ? $progress['status'] : 'pending';
1428
1429 self::markAjaxResponseSent();
1430 self::getAndClearAjaxBufferedOutput();
1431 self::sendJsonResponseAndExit(array(
1432 'status' => $statusValue,
1433 'progress' => is_array($progress) ? $progress : array(),
1434 ), 200);
1435 return;
1436
1437 } catch (Throwable $e) {
1438 if (!$isPluginAdmin) {
1439 $abj404logic = abj_service('plugin_logic');
1440 if (is_object($abj404logic) && method_exists($abj404logic, 'userIsPluginAdmin')) {
1441 try {
1442 $isPluginAdmin = (bool)$abj404logic->userIsPluginAdmin();
1443 } catch (Throwable $ignored) {
1444 $isPluginAdmin = false;
1445 }
1446 }
1447 }
1448
1449 $details = array(
1450 'exception' => array(
1451 'message' => $e->getMessage(),
1452 'file' => $e->getFile(),
1453 'line' => $e->getLine(),
1454 'trace' => $e->getTraceAsString(),
1455 ),
1456 'context' => $context,
1457 );
1458 self::safeLogAjaxFailure('AJAX exception in ajaxAdvanceViewBuild.', $details, $e);
1459 $capturedOutput = self::getAndClearAjaxBufferedOutput();
1460 if ($capturedOutput !== '') {
1461 $details['buffered_output'] = substr($capturedOutput, 0, 8000);
1462 }
1463
1464 self::markAjaxResponseSent();
1465 self::sendJsonResponseAndExit(
1466 self::buildAjaxErrorResponse('Server error while advancing the view build.', $details, $isPluginAdmin),
1467 500
1468 );
1469 return;
1470 }
1471 }
1472
1473 }
1474