PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / 404-solution.php

404-solution.php in 404 Solution 4.2.0, at 404-solution.php

1,755 lines 68.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 /*
9 Plugin Name: 404 Solution
10 Plugin URI: https://www.ajexperience.com/404-solution/
11 Description: The smartest 404 plugin - uses intelligent matching and spell-checking to find what visitors were actually looking for, not just redirect to homepage
12 Author: Aaron J
13 Author URI: https://www.ajexperience.com/404-solution/
14
15 Version: 4.2.0
16 Requires at least: 5.0
17 Requires PHP: 7.4
18
19 License: GPL-3.0-or-later
20 License URI: https://www.gnu.org/licenses/gpl-3.0.html
21 Domain Path: /languages
22 Text Domain: 404-solution
23
24 This program is free software; you can redistribute it and/or modify
25 it under the terms of the GNU General Public License as published by
26 the Free Software Foundation; either version 2 of the License, or
27 (at your option) any later version.
28
29 This program is distributed in the hope that it will be useful,
30 but WITHOUT ANY WARRANTY; without even the implied warranty of
31 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
32 GNU General Public License for more details.
33
34 You should have received a copy of the GNU General Public License
35 along with this program; if not, write to the Free Software
36 Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
37 */
38
39 // Guard constant definitions so unit tests (and unusual loaders) can define them first.
40 if (!defined('ABJ404_PP')) {
41 define('ABJ404_PP', 'abj404_solution');
42 }
43 if (!defined('ABJ404_FILE')) {
44 define('ABJ404_FILE', __FILE__);
45 }
46 if (!defined('ABJ404_PATH')) {
47 define('ABJ404_PATH', plugin_dir_path(ABJ404_FILE));
48 }
49 if (!defined('ABJ404_SHORTCODE_NAME')) {
50 define('ABJ404_SHORTCODE_NAME', 'abj404_solution_page_suggestions');
51 }
52 if (!isset($GLOBALS['abj404_display_errors'])) {
53 $GLOBALS['abj404_display_errors'] = false;
54 }
55
56 // Boot state: tracks whether the plugin loaded successfully.
57 // If a required file is missing or Loader.php fails, these let us show
58 // a degraded admin page instead of a fatal error.
59 $GLOBALS['abj404_boot_ok'] = false;
60 $GLOBALS['abj404_missing_files'] = array();
61 $GLOBALS['abj404_boot_error'] = '';
62
63 // Used by multiple classes during early admin initialization (e.g. upgrade/migration paths).
64 // This must be defined before any Loader.php initialization that might touch Logging/SynchronizationUtils.
65 if (!function_exists('abj404_getUploadsDir')) {
66 /** @return string */
67 function abj404_getUploadsDir() {
68 $uploadsDirArray = wp_upload_dir(null, false);
69 $uploadsDir = $uploadsDirArray['basedir'];
70 $uploadsDir .= DIRECTORY_SEPARATOR . 'temp_' . ABJ404_PP . DIRECTORY_SEPARATOR;
71 return $uploadsDir;
72 }
73 }
74
75 if (!function_exists('abj404_get_settings_options')) {
76 /**
77 * Centralized settings read so call sites don't repeat option-shape checks.
78 *
79 * @return array<string, mixed>
80 */
81 function abj404_get_settings_options() {
82 $options = get_option('abj404_settings');
83 return is_array($options) ? $options : array();
84 }
85 }
86
87 // Debug whitelist - only includes localhost/development environments by default
88 // WARNING: Only add trusted domains to this list. External domains could be a security risk.
89 // This list is used to enable detailed error logging for debugging purposes.
90 $GLOBALS['abj404_whitelist'] = array('127.0.0.1', '::1', 'localhost');
91
92 // Allow filtering the whitelist for advanced users who need to add custom domains
93 // Usage: add_filter('abj404_debug_whitelist', function($whitelist) { $whitelist[] = 'yourdomain.com'; return $whitelist; });
94 if (has_filter('abj404_debug_whitelist')) {
95 $GLOBALS['abj404_whitelist'] = apply_filters('abj404_debug_whitelist', $GLOBALS['abj404_whitelist']);
96 }
97
98 if (!function_exists('abj404_autoloader')) {
99 /**
100 * @param string $class
101 * @return void
102 */
103 function abj404_autoloader($class) {
104 // some people were having issues with possibly parent classes not being loaded before their children.
105 $childParentMap = [
106 'ABJ_404_Solution_FunctionsMBString' => 'ABJ_404_Solution_Functions',
107 'ABJ_404_Solution_FunctionsPreg' => 'ABJ_404_Solution_Functions',
108 ];
109
110 // only pay attention if it's for us. don't bother for other things.
111 if (substr($class, 0, 16) !== 'ABJ_404_Solution') {
112 return;
113 }
114
115 // Use a deterministic classmap to avoid runtime glob() scans on real sites.
116 static $abj404_autoLoaderClassMap = null;
117 if ($abj404_autoLoaderClassMap === null) {
118 $mapFile = __DIR__ . '/includes/classmap.php';
119 $abj404_autoLoaderClassMap = file_exists($mapFile) ? require $mapFile : array();
120 }
121
122 if (!array_key_exists($class, $abj404_autoLoaderClassMap)) {
123 return;
124 }
125
126 // Trait dependency pre-check: classes that use require_once for trait files at file
127 // scope will cause an uncatchable compile-time fatal if any trait file is missing.
128 // Verify all trait files exist BEFORE loading the parent class.
129 static $traitDependencies = null;
130 if ($traitDependencies === null) {
131 // Use __DIR__ (not ABJ404_PATH) to match the classmap's path resolution.
132 $inc = __DIR__ . '/includes/';
133 $traitDependencies = array(
134 'ABJ_404_Solution_View' => array(
135 $inc . 'ViewTrait_Shared.php',
136 $inc . 'ViewTrait_UI.php',
137 $inc . 'ViewTrait_Stats.php',
138 $inc . 'ViewTrait_Settings.php',
139 $inc . 'ViewTrait_Redirects.php',
140 $inc . 'ViewTrait_RedirectsTable.php',
141 $inc . 'ViewTrait_RedirectTypeUI.php',
142 $inc . 'ViewTrait_RedirectConditions.php',
143 $inc . 'ViewTrait_Logs.php',
144 ),
145 'ABJ_404_Solution_DataAccess' => array(
146 $inc . 'DatabaseCore.php',
147 $inc . 'ContentRepository.php',
148 $inc . 'RedirectsRepository.php',
149 $inc . 'LogsRepository.php',
150 $inc . 'StatsRepository.php',
151 $inc . 'ViewReadService.php',
152 $inc . 'ViewBuildOrchestrator.php',
153 ),
154 'ABJ_404_Solution_PluginLogic' => array(
155 $inc . 'PluginLogicUrlNormalization.php',
156 $inc . 'PluginLogicAdminActions.php',
157 $inc . 'PluginLogicImportExport.php',
158 $inc . 'PluginLogicSettingsUpdate.php',
159 $inc . 'PluginLogicPageOrdering.php',
160 $inc . 'PluginLogicLifecycle.php',
161 ),
162 'ABJ_404_Solution_DatabaseUpgradesEtc' => array(
163 $inc . 'DatabaseUpgradesEtcTrait_NGram.php',
164 $inc . 'DatabaseUpgradesEtcTrait_Maintenance.php',
165 $inc . 'DatabaseUpgradesEtcTrait_PluginUpdate.php',
166 $inc . 'DatabaseUpgradesEtcTrait_TableRepair.php',
167 $inc . 'DatabaseUpgradesEtcTrait_Indexes.php',
168 $inc . 'DatabaseUpgradesEtcTrait_OrphanAdoption.php',
169 $inc . 'DatabaseUpgradesEtcTrait_MultiSite.php',
170 $inc . 'DatabaseUpgradesEtcTrait_SchemaDiff.php',
171 ),
172 // AJAX handler classes that pull in shared traits via `use`.
173 // Without these entries, a corrupted upload that loses the trait
174 // file would cause an uncatchable compile fatal in the host class.
175 'ABJ_404_Solution_Ajax_TrashLink' => array(
176 $inc . 'ajax/AjaxSecurityTrait.php',
177 ),
178 'ABJ_404_Solution_Ajax_TrendData' => array(
179 $inc . 'ajax/AjaxSecurityTrait.php',
180 ),
181 'ABJ_404_Solution_Ajax_CrossPluginImporter' => array(
182 $inc . 'ajax/AjaxSecurityTrait.php',
183 ),
184 'ABJ_404_Solution_Ajax_EngineProfiles' => array(
185 $inc . 'ajax/AjaxSecurityTrait.php',
186 ),
187 'ABJ_404_Solution_Ajax_SettingsModeToggle' => array(
188 $inc . 'ajax/AjaxSecurityTrait.php',
189 ),
190 'ABJ_404_Solution_Ajax_SupportRequest' => array(
191 $inc . 'ajax/AjaxSecurityTrait.php',
192 ),
193 'ABJ_404_Solution_Ajax_SupportRequestPreview' => array(
194 $inc . 'ajax/AjaxSecurityTrait.php',
195 ),
196 'ABJ_404_Solution_Ajax_RestoreDefaults' => array(
197 $inc . 'ajax/AjaxSecurityTrait.php',
198 ),
199 'ABJ_404_Solution_ViewUpdater' => array(
200 $inc . 'ajax/AjaxFailureLoggingTrait.php',
201 ),
202 'ABJ_404_Solution_FeedbackTransport' => array(
203 $inc . 'FeedbackEnvironmentExtras.php',
204 ),
205 'ABJ_404_Solution_DatabaseCore' => array(
206 $inc . 'DatabaseConnectionManager.php',
207 $inc . 'DatabaseQueryTimeoutManager.php',
208 $inc . 'DatabaseErrorClassifier.php',
209 $inc . 'DatabaseSqlErrorReporter.php',
210 ),
211 );
212 }
213
214 if (isset($traitDependencies[$class])) {
215 foreach ($traitDependencies[$class] as $traitFile) {
216 if (!file_exists($traitFile)) {
217 $GLOBALS['abj404_missing_files'][] = $traitFile;
218 // Don't load the parent class — the compile-time fatal is uncatchable.
219 return;
220 }
221 }
222 }
223
224 // Ensure the parent class is loaded first.
225 if (array_key_exists($class, $childParentMap)) {
226 $parentClass = $childParentMap[$class];
227 if (!class_exists($parentClass, false) && array_key_exists($parentClass, $abj404_autoLoaderClassMap)) {
228 $parentFile = $abj404_autoLoaderClassMap[$parentClass];
229 if (!file_exists($parentFile)) {
230 $GLOBALS['abj404_missing_files'][] = $parentFile;
231 return;
232 }
233 require_once $parentFile;
234 }
235 }
236
237 $classFile = $abj404_autoLoaderClassMap[$class];
238 if (!file_exists($classFile)) {
239 $GLOBALS['abj404_missing_files'][] = $classFile;
240 return;
241 }
242
243 require_once $classFile;
244 }
245 }
246 spl_autoload_register('abj404_autoloader');
247
248
249 add_action('doing_it_wrong_run', function($function_name, $message, $version) {
250 if (strpos($message, '404-solution') !== false &&
251 $function_name == '_load_textdomain_just_in_time') {
252
253 try {
254 $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
255
256 // Prepare the plugin path from ABJ404_FILE
257 $pluginPath = trailingslashit(plugin_dir_path(ABJ404_FILE)); // e.g., /var/www/html/wp-content/plugins/404-solution/
258
259 $logMessage = '';
260 $isOurPlugin = false;
261
262 foreach ($backtrace as $index => $frame) {
263 $file = isset($frame['file']) ? $frame['file'] : '[internal function]';
264 $line = isset($frame['line']) ? $frame['line'] : '';
265 $func = $frame['function'];
266
267 if (!$isOurPlugin && is_string($file) && strpos($file, $pluginPath) !== false) {
268 $isOurPlugin = true;
269 }
270
271 $logMessage .= "#$index $func at [$file:$line]\n";
272 }
273
274 if ($isOurPlugin) {
275 $header = "=== Detected Early Translation ===\n" .
276 "Function: $function_name\n" .
277 "Message: $message\n" .
278 "Version: $version\n";
279
280 if (!isset($GLOBALS['abj404_pending_errors'])) {
281 $GLOBALS['abj404_pending_errors'] = [];
282 }
283 $GLOBALS['abj404_pending_errors'][] = $header . $logMessage;
284 }
285
286 } catch (Throwable $e) {
287 error_log('404 Solution: failed to capture early translation stack trace: ' .
288 $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
289 }
290 }
291 }, 10, 3);
292
293 // shortcode
294 add_shortcode(ABJ404_SHORTCODE_NAME, 'abj404_shortCodeListener');
295 if (!function_exists('abj404_shortCodeListener')) {
296 /**
297 * @param array<string, mixed>|string $atts
298 * @return string
299 */
300 function abj404_shortCodeListener($atts) {
301 if (!$GLOBALS['abj404_boot_ok']) {
302 return '';
303 }
304 abj404_load_textdomain_if_needed();
305 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
306 /** @var array<string, mixed> $safeAtts */
307 $safeAtts = is_array($atts) ? $atts : array();
308 return ABJ_404_Solution_ShortCode::shortcodePageSuggestions($safeAtts);
309 }
310
311 if (!function_exists('abj404_get_required_runtime_files')) {
312 /**
313 * Files required for a healthy runtime/plugin package.
314 * Covers boot-critical PHP files and essential SQL templates.
315 *
316 * @return array<int, string>
317 */
318 function abj404_get_required_runtime_files() {
319 $inc = ABJ404_PATH . 'includes/';
320 return array(
321 // Boot-critical
322 $inc . 'Loader.php',
323 $inc . 'bootstrap.php',
324 $inc . 'classmap.php',
325 $inc . 'ServiceContainer.php',
326 $inc . 'Clock.php',
327 $inc . 'ErrorHandler.php',
328 // Core classes
329 $inc . 'WordPress_Connector.php',
330 $inc . 'Functions.php',
331 $inc . 'Logging.php',
332 $inc . 'FrontendRequestPipeline.php',
333 $inc . 'ImportExportService.php',
334 $inc . 'QueryBudgetInstrumentation.php',
335 // Support-request button + AJAX. Listed here so that a
336 // corrupt install which lost any of these files is
337 // surfaced to the admin (the "missing files" list on
338 // the degraded admin page is what tells the user what
339 // to re-upload). The degraded admin page itself uses
340 // these files when present to render the in-page
341 // support button.
342 $inc . 'SupportRequestButton.php',
343 $inc . 'FeedbackTransport.php',
344 $inc . 'ajax/AjaxSecurityTrait.php',
345 $inc . 'ajax/Ajax_SupportRequest.php',
346 $inc . 'ajax/Ajax_SupportRequestPreview.php',
347 $inc . 'ajax/SupportRequest.js',
348 $inc . 'js/support-request-button.js',
349 // View + traits
350 $inc . 'View.php',
351 $inc . 'ViewTrait_Shared.php',
352 $inc . 'ViewTrait_UI.php',
353 $inc . 'ViewTrait_Stats.php',
354 $inc . 'ViewTrait_Settings.php',
355 $inc . 'ViewTrait_Redirects.php',
356 $inc . 'ViewTrait_RedirectsTable.php',
357 $inc . 'ViewTrait_RedirectTypeUI.php',
358 $inc . 'ViewTrait_RedirectConditions.php',
359 $inc . 'ViewTrait_Logs.php',
360 // DataAccess + traits + extracted modules
361 $inc . 'DataAccess.php',
362 $inc . 'ContentRepositoryInterface.php',
363 $inc . 'ContentRepository.php',
364 $inc . 'RedirectsRepositoryInterface.php',
365 $inc . 'RedirectsRepository.php',
366 $inc . 'LogsRepositoryInterface.php',
367 $inc . 'LogsRepository.php',
368 $inc . 'StatsRepositoryInterface.php',
369 $inc . 'StatsRepository.php',
370 $inc . 'ViewReadServiceInterface.php',
371 $inc . 'ViewReadService.php',
372 $inc . 'ViewBuildOrchestratorInterface.php',
373 $inc . 'ViewBuildOrchestrator.php',
374 $inc . 'DatabaseCoreInterface.php',
375 $inc . 'DatabaseCore.php',
376 // PluginLogic + composition classes
377 $inc . 'PluginLogic.php',
378 $inc . 'PluginLogicUrlNormalization.php',
379 $inc . 'PluginLogicAdminActions.php',
380 $inc . 'PluginLogicImportExport.php',
381 $inc . 'PluginLogicSettingsUpdate.php',
382 $inc . 'PluginLogicPageOrdering.php',
383 $inc . 'PluginLogicLifecycle.php',
384 // SpellChecker + delegate classes
385 $inc . 'SpellChecker.php',
386 $inc . 'SpellURLMatcher.php',
387 $inc . 'SpellPostListeners.php',
388 $inc . 'SpellLevenshteinEngine.php',
389 $inc . 'SpellCandidateFilter.php',
390 // DatabaseUpgradesEtc + traits
391 $inc . 'DatabaseUpgradesEtc.php',
392 $inc . 'DatabaseUpgradesEtcTrait_NGram.php',
393 $inc . 'DatabaseUpgradesEtcTrait_Maintenance.php',
394 $inc . 'DatabaseUpgradesEtcTrait_PluginUpdate.php',
395 $inc . 'DatabaseUpgradesEtcTrait_TableRepair.php',
396 $inc . 'DatabaseUpgradesEtcTrait_Indexes.php',
397 $inc . 'DatabaseUpgradesEtcTrait_OrphanAdoption.php',
398 $inc . 'DatabaseUpgradesEtcTrait_MultiSite.php',
399 $inc . 'DatabaseUpgradesEtcTrait_SchemaDiff.php',
400 // SQL templates — all files required for correct operation.
401 // A test (SqlFileIntegrityListCompletenessTest) verifies this list
402 // stays in sync with the actual files in includes/sql/.
403 $inc . 'sql/correctLookupTableIssue.sql',
404 $inc . 'sql/createEngineProfilesTable.sql',
405 $inc . 'sql/createLogTable.sql',
406 $inc . 'sql/createLogsHitsPreAggTempTable.sql',
407 $inc . 'sql/createLogsHitsTempTable.sql',
408 $inc . 'sql/createLookupTable.sql',
409 $inc . 'sql/createNGramCacheTable.sql',
410 $inc . 'sql/createPermalinkCacheTable.sql',
411 $inc . 'sql/createRedirectConditionsTable.sql',
412 $inc . 'sql/createRedirectsTable.sql',
413 $inc . 'sql/createSpellingCacheTable.sql',
414 $inc . 'sql/createViewBuildTable.sql',
415 $inc . 'sql/createViewCacheTable.sql',
416 $inc . 'sql/deleteOldLogs.sql',
417 $inc . 'sql/getAdditionalPostData.sql',
418 $inc . 'sql/getIDsNeededForPermalinkCache.sql',
419 $inc . 'sql/getLogRecords.sql',
420 $inc . 'sql/getLogsCount.sql',
421 $inc . 'sql/getDistinctLoggedUrls.sql',
422 $inc . 'sql/getLogsIDandURL.sql',
423 $inc . 'sql/getLogsIDandURLForAjax.sql',
424 $inc . 'sql/getMostUnusedRedirects.sql',
425 $inc . 'sql/getOrphanedAutoRedirects.sql',
426 $inc . 'sql/getPermalinkFromURL.sql',
427 $inc . 'sql/getPostsNeedingContentKeywords.sql',
428 $inc . 'sql/getPublishedCategories.sql',
429 $inc . 'sql/getPublishedImageIDs.sql',
430 $inc . 'sql/getPublishedPagesAndPostsIDs.sql',
431 $inc . 'sql/getPublishedTags.sql',
432 $inc . 'sql/getRedirectsExport.sql',
433 $inc . 'sql/getRedirectsForView.sql',
434 $inc . 'sql/getRedirectsForViewTempTable.sql',
435 $inc . 'sql/getRedirectsWithLogs.sql',
436 $inc . 'sql/importDataFromPluginRedirectioner.sql',
437 $inc . 'sql/insertPermalinkCache.sql',
438 $inc . 'sql/insertSpellingCache.sql',
439 $inc . 'sql/logsSetMinLogID.sql',
440 $inc . 'sql/migrateToNewLogsTable.sql',
441 $inc . 'sql/selectTableEngines.sql',
442 $inc . 'sql/updatePermalinkCache.sql',
443 $inc . 'sql/updatePermalinkCacheParentPages.sql',
444 );
445 }
446 }
447
448 if (!function_exists('abj404_verify_runtime_integrity')) {
449 /**
450 * Validate that required plugin files are present.
451 *
452 * @return array<int, string> Missing file paths.
453 */
454 function abj404_verify_runtime_integrity() {
455 $missing = array();
456 foreach (abj404_get_required_runtime_files() as $path) {
457 if (!file_exists($path)) {
458 $missing[] = $path;
459 }
460 }
461 return $missing;
462 }
463 }
464
465 if (!function_exists('abj404_is_benchmark_request')) {
466 /**
467 * Benchmark instrumentation is disabled by default and only enabled per-request.
468 *
469 * @return bool
470 */
471 function abj404_is_benchmark_request() {
472 return isset($_GET['abj404_bench']) && (string)$_GET['abj404_bench'] === '1';
473 }
474 }
475
476 if (!function_exists('abj404_benchmark_bootstrap_start')) {
477 /** @return void */
478 function abj404_benchmark_bootstrap_start() {
479 if (!abj404_is_benchmark_request()) {
480 return;
481 }
482 if (!isset($GLOBALS['abj404_benchmark_state']) || !is_array($GLOBALS['abj404_benchmark_state'])) {
483 $GLOBALS['abj404_benchmark_state'] = array(
484 'start' => microtime(true),
485 'bootstrap_done' => 0.0,
486 'db_query_count' => 0,
487 'db_query_ms' => 0.0,
488 'redirect_lookup_ms' => 0.0,
489 );
490 }
491 }
492 }
493
494 if (!function_exists('abj404_benchmark_mark_bootstrap_done')) {
495 /** @return void */
496 function abj404_benchmark_mark_bootstrap_done() {
497 if (!abj404_is_benchmark_request() || !isset($GLOBALS['abj404_benchmark_state'])) {
498 return;
499 }
500 $GLOBALS['abj404_benchmark_state']['bootstrap_done'] = microtime(true);
501 }
502 }
503
504 if (!function_exists('abj404_benchmark_record_db_query')) {
505 /**
506 * @param float $elapsedMs
507 * @return void
508 */
509 function abj404_benchmark_record_db_query($elapsedMs) {
510 if (!abj404_is_benchmark_request() || !isset($GLOBALS['abj404_benchmark_state'])) {
511 return;
512 }
513 $elapsedMs = max(0.0, (float)$elapsedMs);
514 $GLOBALS['abj404_benchmark_state']['db_query_count']++;
515 $GLOBALS['abj404_benchmark_state']['db_query_ms'] += $elapsedMs;
516 }
517 }
518
519 if (!function_exists('abj404_benchmark_record_redirect_lookup')) {
520 /**
521 * @param float $elapsedMs
522 * @return void
523 */
524 function abj404_benchmark_record_redirect_lookup($elapsedMs) {
525 if (!abj404_is_benchmark_request() || !isset($GLOBALS['abj404_benchmark_state'])) {
526 return;
527 }
528 $GLOBALS['abj404_benchmark_state']['redirect_lookup_ms'] += max(0.0, (float)$elapsedMs);
529 }
530 }
531
532 if (!function_exists('abj404_benchmark_emit_headers')) {
533 /** @return void */
534 function abj404_benchmark_emit_headers() {
535 if (!abj404_is_benchmark_request() || headers_sent() || !isset($GLOBALS['abj404_benchmark_state'])) {
536 return;
537 }
538 $state = $GLOBALS['abj404_benchmark_state'];
539 $start = isset($state['start']) ? (float)$state['start'] : 0.0;
540 $bootstrapDone = isset($state['bootstrap_done']) ? (float)$state['bootstrap_done'] : 0.0;
541 $now = microtime(true);
542 $totalMs = ($start > 0.0) ? (($now - $start) * 1000.0) : 0.0;
543 $bootstrapMs = ($start > 0.0 && $bootstrapDone > 0.0) ? (($bootstrapDone - $start) * 1000.0) : 0.0;
544 $dbQueryCount = isset($state['db_query_count']) ? (int)$state['db_query_count'] : 0;
545 $dbQueryMs = isset($state['db_query_ms']) ? (float)$state['db_query_ms'] : 0.0;
546 $redirectLookupMs = isset($state['redirect_lookup_ms']) ? (float)$state['redirect_lookup_ms'] : 0.0;
547
548 header(
549 'X-ABJ404-Benchmark: ' .
550 'total_ms=' . round($totalMs, 3) . ';' .
551 'bootstrap_ms=' . round($bootstrapMs, 3) . ';' .
552 'db_query_count=' . $dbQueryCount . ';' .
553 'db_query_ms=' . round($dbQueryMs, 3) . ';' .
554 'redirect_lookup_ms=' . round($redirectLookupMs, 3)
555 );
556 }
557 }
558
559 abj404_benchmark_bootstrap_start();
560 if (abj404_is_benchmark_request()) {
561 add_action('send_headers', 'abj404_benchmark_emit_headers', PHP_INT_MAX);
562 }
563 }
564
565 // Minimal shutdown handler: catches compile/parse fatals in plugin files and
566 // stores them in a transient so the degraded admin page can display the error
567 // on the next request. This is important for PHP 7.4 where syntax errors in
568 // required files produce uncatchable E_COMPILE_ERROR.
569 if (!function_exists('abj404_boot_shutdown_handler')) {
570 /** @return void */
571 function abj404_boot_shutdown_handler() {
572 if ($GLOBALS['abj404_boot_ok']) {
573 return;
574 }
575 $error = error_get_last();
576 if ($error === null) {
577 return;
578 }
579 // Only capture fatal/compile errors in our plugin files.
580 $fatalTypes = E_ERROR | E_PARSE | E_COMPILE_ERROR | E_CORE_ERROR;
581 if (!($error['type'] & $fatalTypes)) {
582 return;
583 }
584 $pluginDir = defined('ABJ404_PATH') ? ABJ404_PATH : __DIR__ . '/';
585 if (strpos($error['file'], $pluginDir) === false) {
586 return;
587 }
588 $errorInfo = array(
589 'message' => $error['message'],
590 'file' => $error['file'],
591 'line' => $error['line'],
592 'type' => $error['type'],
593 'time' => time(),
594 );
595 // Use update_option as a fallback — set_transient might not be available
596 // during a fatal shutdown.
597 if (function_exists('set_transient')) {
598 set_transient('abj404_boot_fatal', $errorInfo, 3600);
599 }
600 }
601 }
602 register_shutdown_function('abj404_boot_shutdown_handler');
603
604 // Always load Loader.php to ensure plugin constants (ABJ404_TYPE_404_DISPLAYED,
605 // ABJ404_STATUS_MANUAL, etc.) are defined in all contexts: admin, REST API, WP-CLI
606 // eval, and template_redirect. Without this, direct calls to plugin classes via
607 // wp eval fail with "Undefined constant" errors because Loader.php was previously
608 // only loaded inside is_admin() — leaving WP-CLI and other non-admin contexts
609 // without the constants they need.
610 $__abj404_loader_path = plugin_dir_path( __FILE__ ) . "includes/Loader.php";
611 if (file_exists($__abj404_loader_path)) {
612 try {
613 require_once($__abj404_loader_path);
614 $GLOBALS['abj404_boot_ok'] = true;
615 // Clear any stale boot fatal transient from a previous failed load.
616 if (function_exists('delete_transient')) {
617 delete_transient('abj404_boot_fatal');
618 }
619 } catch (\Throwable $e) {
620 $GLOBALS['abj404_boot_ok'] = false;
621 $GLOBALS['abj404_boot_error'] = $e->getMessage();
622 error_log('404 Solution: boot failed — ' . $e->getMessage());
623 }
624 } else {
625 $GLOBALS['abj404_boot_ok'] = false;
626 $GLOBALS['abj404_missing_files'][] = $__abj404_loader_path;
627 $GLOBALS['abj404_boot_error'] = 'Loader.php is missing.';
628 }
629 unset($__abj404_loader_path);
630
631 if ($GLOBALS['abj404_boot_ok']) {
632 // admin
633 if (is_admin()) {
634 try {
635 ABJ_404_Solution_WordPress_Connector::init();
636 ABJ_404_Solution_ViewUpdater::init();
637 } catch (\Throwable $e) {
638 // init() failed — fall through to register the degraded admin page
639 // so the user still has a menu item with error details instead of nothing.
640 $GLOBALS['abj404_boot_ok'] = false;
641 $GLOBALS['abj404_boot_error'] = 'Plugin initialization failed: ' . $e->getMessage();
642 error_log('404 Solution: admin initialization failed: ' .
643 $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
644 add_action('admin_menu', 'abj404_degraded_admin_menu');
645 add_action('admin_notices', 'abj404_degraded_admin_notice');
646 }
647 }
648
649 // REST API — deferred to rest_api_init so DataAccess/PluginLogic are only loaded on actual REST requests.
650 add_action('rest_api_init', function() {
651 $dao = ABJ_404_Solution_DataAccess::getInstance();
652 $logic = ABJ_404_Solution_PluginLogic::getInstance();
653 $restController = new ABJ_404_Solution_RestApiController($dao, $logic);
654 $restController->registerRoutes();
655 });
656
657 // WP-CLI commands.
658 if (defined('WP_CLI') && WP_CLI) {
659 add_action('init', function() {
660 \WP_CLI::add_command('abj404', 'ABJ_404_Solution_WPCLICommands');
661 }, 1);
662 }
663 } elseif (function_exists('is_admin') && is_admin()) {
664 // Boot failed. Register degraded admin page so the admin sees instructions
665 // instead of a white screen or missing menu item.
666 add_action('admin_menu', 'abj404_degraded_admin_menu');
667 add_action('admin_notices', 'abj404_degraded_admin_notice');
668 // Best-effort: wire the support-request flow even on the degraded
669 // boot path so an admin who lands here can still send a debug report.
670 // This is the most valuable placement for the button, because the user
671 // often cannot reach the normal plugin UI from this screen. Deferred
672 // to plugins_loaded so the function (defined below) is available
673 // regardless of source-order; PHP does not hoist function definitions
674 // out of conditional blocks.
675 if (function_exists('add_action')) {
676 add_action('plugins_loaded', 'abj404_degraded_register_support_request');
677 }
678 }
679
680 // --- Degraded-mode functions (always defined, no plugin class dependencies) ---
681
682 if (!function_exists('abj404_degraded_register_support_request')) {
683 /**
684 * Wire the support-request flow on the degraded boot path so an admin
685 * stuck on the corrupt-install screen can still send a debug log. The
686 * normal registration runs inside WordPress_Connector::registerAdminHooks()
687 * which only executes on a successful boot; without this helper, the
688 * AJAX handler that the modal POSTs to does not exist on the degraded
689 * path and the click silently 400s.
690 *
691 * Strictly best-effort. Every step is guarded with file_exists() and
692 * class_exists() so a corrupted install that is missing any of the
693 * support-request files just falls back to the mailto link in the
694 * degraded admin page. We must not throw a fatal here, because we
695 * already ARE on the degraded path.
696 *
697 * @return void
698 */
699 function abj404_degraded_register_support_request() {
700 $inc = ABJ404_PATH . 'includes/';
701 $supportFiles = array(
702 $inc . 'ajax/AjaxSecurityTrait.php',
703 $inc . 'FeedbackTransport.php',
704 $inc . 'SupportRequestButton.php',
705 $inc . 'ajax/Ajax_SupportRequest.php',
706 $inc . 'ajax/Ajax_SupportRequestPreview.php',
707 );
708 foreach ($supportFiles as $file) {
709 if (!file_exists($file)) {
710 return;
711 }
712 }
713 try {
714 foreach ($supportFiles as $file) {
715 require_once $file;
716 }
717 // allow-silent-catch: degraded boot path. If any of the support-request files compile-fatals on require we want to fall through to the mailto fallback rather than crash the corrupt-install screen the user is here to read.
718 } catch (\Throwable $e) {
719 return;
720 }
721 if (!class_exists('ABJ_404_Solution_Ajax_SupportRequest')
722 || !class_exists('ABJ_404_Solution_Ajax_SupportRequestPreview')) {
723 return;
724 }
725 // Register the AJAX handlers directly. The normal init() path goes
726 // through ABJ_404_Solution_WPUtils::safeAddAction() but that helper
727 // may itself be missing on a corrupt install; falling back to
728 // add_action() avoids that dependency.
729 $supportInstance = ABJ_404_Solution_Ajax_SupportRequest::getInstance();
730 $previewInstance = ABJ_404_Solution_Ajax_SupportRequestPreview::getInstance();
731 add_action('wp_ajax_abj404_support_request', array($supportInstance, 'handleRequest'));
732 add_action('wp_ajax_abj404_support_request_preview', array($previewInstance, 'handleRequest'));
733 // Enqueue the JS assets on the degraded admin page only. Using a
734 // closure keeps this self-contained without registering a new
735 // global function on the corrupted boot path.
736 add_action('admin_enqueue_scripts', function($hook) use ($inc) {
737 $ppSlug = defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution';
738 $isOurPage = is_string($hook) && (
739 strpos($hook, $ppSlug) !== false
740 || strpos($hook, 'abj404_solution') !== false
741 );
742 if (!$isOurPage) {
743 return;
744 }
745 $clientJs = $inc . 'ajax/SupportRequest.js';
746 $buttonJs = $inc . 'js/support-request-button.js';
747 if (!file_exists($clientJs) || !file_exists($buttonJs)) {
748 return;
749 }
750 $baseUrl = plugin_dir_url(__FILE__) . 'includes/';
751 $ver = defined('ABJ404_VERSION') ? ABJ404_VERSION : (string)time();
752 wp_enqueue_script('abj404-support-request-client',
753 $baseUrl . 'ajax/SupportRequest.js', array(), $ver, true);
754 wp_enqueue_script('abj404-support-request-button',
755 $baseUrl . 'js/support-request-button.js',
756 array('abj404-support-request-client'), $ver, true);
757 $supportNonce = wp_create_nonce(ABJ_404_Solution_Ajax_SupportRequest::NONCE_ACTION);
758 $previewNonce = wp_create_nonce(ABJ_404_Solution_Ajax_SupportRequestPreview::NONCE_ACTION);
759 $ajaxUrl = admin_url('admin-ajax.php');
760 $payload = wp_json_encode(array(
761 'ajaxurl' => $ajaxUrl,
762 'nonces' => array(
763 'support_request' => $supportNonce,
764 'support_request_preview' => $previewNonce,
765 ),
766 ));
767 $bootstrap = 'window.ABJ404=window.ABJ404||{};Object.assign(window.ABJ404,'
768 . (is_string($payload) ? $payload : '{}') . ');';
769 wp_add_inline_script('abj404-support-request-client', $bootstrap, 'before');
770 });
771 }
772 }
773
774 if (!function_exists('abj404_degraded_admin_menu')) {
775 /** @return void */
776 function abj404_degraded_admin_menu() {
777 $options = function_exists('get_option') ? get_option('abj404_settings') : false;
778 $options = is_array($options) ? $options : array();
779
780 $menuName = '404 Solution';
781 $badge = " <span class='update-plugins count-1'><span class='plugin-count'>!</span></span>";
782
783 if (isset($options['menuLocation']) && $options['menuLocation'] === 'settingsLevel') {
784 add_menu_page('404 Solution', $menuName . $badge, 'manage_options', 'abj404_solution', 'abj404_degraded_admin_page');
785 } else {
786 $ppSlug = defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution';
787 add_submenu_page('options-general.php', '404 Solution', $menuName . $badge, 'manage_options', $ppSlug, 'abj404_degraded_admin_page');
788 }
789 }
790 }
791
792 if (!function_exists('abj404_degraded_admin_notice')) {
793 /** @return void */
794 function abj404_degraded_admin_notice() {
795 if (!current_user_can('manage_options')) {
796 return;
797 }
798 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ';
799 echo 'Plugin files are missing or corrupt. ';
800 $ppSlug = defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution';
801 echo '<a href="' . esc_url(admin_url('options-general.php?page=' . $ppSlug)) . '">View details</a>';
802 echo '</p></div>';
803 }
804 }
805
806 if (!function_exists('abj404_degraded_admin_page')) {
807 /** @return void */
808 function abj404_degraded_admin_page() {
809 if (!current_user_can('manage_options')) {
810 echo '<div class="wrap">';
811 echo '<h1>404 Solution</h1>';
812 echo '<div class="notice notice-error"><p>';
813 echo '<strong>Permission denied.</strong> ';
814 echo 'Your user account does not have permission to access this page.';
815 echo '</p><p>';
816 echo 'Please verify that your WordPress role has the <code>manage_options</code> capability.';
817 echo '</p></div></div>';
818 return;
819 }
820
821 $missingFiles = isset($GLOBALS['abj404_missing_files']) ? $GLOBALS['abj404_missing_files'] : array();
822 $bootError = isset($GLOBALS['abj404_boot_error']) ? $GLOBALS['abj404_boot_error'] : '';
823 $pluginDir = defined('ABJ404_PATH') ? ABJ404_PATH : dirname(__FILE__) . '/';
824
825 // Check for a stored fatal from a previous request.
826 $fatalInfo = function_exists('get_transient') ? get_transient('abj404_boot_fatal') : false;
827
828 echo '<div class="wrap">';
829 echo '<h1>404 Solution &mdash; Plugin Files Missing</h1>';
830
831 echo '<div class="notice notice-error inline"><p>';
832 echo '<strong>The 404 Solution plugin cannot start</strong> because one or more required files are missing or corrupt. ';
833 echo 'This usually happens after a failed plugin update or incomplete file upload.';
834 echo '</p></div>';
835
836 if (!empty($missingFiles)) {
837 echo '<div class="card" style="max-width:800px;">';
838 echo '<h2>Missing Files</h2>';
839 echo '<ul style="list-style:disc;padding-left:20px;">';
840 foreach ($missingFiles as $file) {
841 // Show relative path for readability.
842 $relative = str_replace($pluginDir, '', $file);
843 echo '<li><code>' . esc_html($relative) . '</code></li>';
844 }
845 echo '</ul>';
846 echo '</div>';
847 }
848
849 if ($bootError !== '') {
850 echo '<div class="card" style="max-width:800px;">';
851 echo '<h2>Error Details</h2>';
852 echo '<pre style="white-space:pre-wrap;word-break:break-all;">' . esc_html($bootError) . '</pre>';
853 echo '</div>';
854 }
855
856 if (is_array($fatalInfo) && !empty($fatalInfo['message'])) {
857 echo '<div class="card" style="max-width:800px;">';
858 echo '<h2>Fatal Error (previous request)</h2>';
859 $fatalFile = isset($fatalInfo['file']) ? str_replace($pluginDir, '', $fatalInfo['file']) : 'unknown';
860 $fatalLine = isset($fatalInfo['line']) ? $fatalInfo['line'] : '?';
861 echo '<pre style="white-space:pre-wrap;word-break:break-all;">' . esc_html($fatalInfo['message']) . "\n" . esc_html($fatalFile) . ':' . esc_html((string)$fatalLine) . '</pre>';
862 echo '</div>';
863 }
864
865 echo '<div class="card" style="max-width:800px;">';
866 echo '<h2>How to Fix</h2>';
867 echo '<ol>';
868 echo '<li>Go to <strong>Plugins &rarr; Installed Plugins</strong>, deactivate <strong>404 Solution</strong>, then delete it.</li>';
869 echo '<li>Reinstall from the WordPress plugin directory: ';
870 $installUrl = admin_url('plugin-install.php?s=404+solution&tab=search');
871 echo '<a href="' . esc_url($installUrl) . '" class="button button-primary">Search &ldquo;404 Solution&rdquo;</a>';
872 echo '</li>';
873 echo '<li>Activate the fresh copy. Your redirects and settings are stored in the database and will not be lost.</li>';
874 echo '</ol>';
875 echo '</div>';
876
877 // Support-request card. This is the most valuable placement for
878 // the "Send debug log to developer" button: an admin who reached
879 // this screen needs help and may not be able to navigate the
880 // normal plugin UI. When the SupportRequestButton class is
881 // present (most corruption is partial), render the mount div so
882 // the JS component can take over. Always emit a mailto fallback
883 // underneath in case the JS files are themselves among the
884 // missing files.
885 echo '<div class="card" style="max-width:800px;">';
886 echo '<h2>Need help? Contact the developer</h2>';
887 echo '<p>Send the developer a one-time diagnostic report including the missing-file list above. Your redirects and settings are not shared.</p>';
888 $missingCount = is_array($missingFiles) ? count($missingFiles) : 0;
889 $contextSummary = 'Corrupt install: ' . $missingCount . ' missing file(s)';
890 $bootErrorForSummary = is_scalar($bootError) ? (string)$bootError : '';
891 if ($bootErrorForSummary !== '') {
892 $contextSummary .= '. Boot error: ' . substr($bootErrorForSummary, 0, 200);
893 }
894 if (class_exists('ABJ_404_Solution_SupportRequestButton')) {
895 echo ABJ_404_Solution_SupportRequestButton::render('system_corrupt_install', $contextSummary);
896 }
897 $mailEmail = defined('ABJ404_AUTHOR_EMAIL') ? (string)ABJ404_AUTHOR_EMAIL : '404solution@ajexperience.com';
898 $mailSubject = rawurlencode('404 Solution: corrupt install report');
899 $homeUrlText = '(unknown)';
900 if (function_exists('home_url')) {
901 $homeUrlVal = home_url();
902 $homeUrlText = is_string($homeUrlVal) ? $homeUrlVal : '(unknown)';
903 }
904 $missingFileLines = '';
905 if (is_array($missingFiles)) {
906 $stringMissingFiles = array();
907 foreach ($missingFiles as $entry) {
908 $stringMissingFiles[] = is_scalar($entry) ? (string)$entry : '';
909 }
910 $missingFileLines = implode("\n", $stringMissingFiles);
911 }
912 $bootErrorText = is_scalar($bootError) ? (string)$bootError : '';
913 $mailBody = rawurlencode("Site URL: " . $homeUrlText . "\n"
914 . "Missing files (" . (int)$missingCount . "):\n"
915 . $missingFileLines
916 . "\n\nBoot error:\n" . $bootErrorText);
917 echo '<p style="margin-top:8px;">Or email manually: ';
918 echo '<a href="mailto:' . esc_attr($mailEmail) . '?subject=' . $mailSubject . '&body=' . $mailBody . '">';
919 echo esc_html($mailEmail) . '</a></p>';
920 echo '</div>';
921
922 echo '</div>'; // .wrap
923 }
924 }
925
926 if (!function_exists('abj404_admin_page_callback')) {
927 /**
928 * Show one-time admin fatal diagnostics captured during shutdown.
929 *
930 * @return void
931 */
932 function abj404_render_last_admin_fatal_notice() {
933 if (!function_exists('current_user_can') || !current_user_can('manage_options')) {
934 return;
935 }
936
937 $fatalInfo = function_exists('get_transient') ? get_transient('abj404_admin_fatal') : false;
938 if ($fatalInfo === false && function_exists('get_option')) {
939 $fatalInfo = get_option('abj404_admin_fatal_fallback', false);
940 }
941 if (!is_array($fatalInfo) || empty($fatalInfo['message'])) {
942 return;
943 }
944
945 if (function_exists('delete_transient')) {
946 delete_transient('abj404_admin_fatal');
947 }
948 if (function_exists('delete_option')) {
949 delete_option('abj404_admin_fatal_fallback');
950 }
951
952 $pluginDir = defined('ABJ404_PATH') ? ABJ404_PATH : __DIR__ . '/';
953 $fatalFile = isset($fatalInfo['file']) ? str_replace($pluginDir, '', (string)$fatalInfo['file']) : '(unknown file)';
954 $fatalLine = isset($fatalInfo['line']) ? (int)$fatalInfo['line'] : 0;
955
956 echo '<div class="wrap">';
957 echo '<div class="notice notice-error">';
958 echo '<p><strong>404 Solution:</strong> A fatal error occurred while rendering the previous admin request.</p>';
959 echo '<details><summary>Show error details</summary>';
960 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">' .
961 esc_html((string)$fatalInfo['message'] . "\n" . $fatalFile . ':' . (string)$fatalLine) .
962 '</pre>';
963 echo '</details>';
964 echo '</div>';
965 echo '</div>';
966 }
967
968 /**
969 * Safe wrapper for the admin page callback. Falls back to the degraded
970 * page if the View class was not loaded during boot.
971 *
972 * @return void
973 */
974 function abj404_admin_page_callback() {
975 abj404_render_last_admin_fatal_notice();
976
977 // The false parameter avoids triggering the autoloader — if View was not
978 // loaded during boot, we don't want to attempt loading it again here.
979 if (class_exists('ABJ_404_Solution_View', false)) {
980 ob_start();
981 $renderError = null;
982 try {
983 ABJ_404_Solution_View::handleMainAdminPageActionAndDisplay();
984 } catch (\Throwable $e) {
985 $renderError = $e;
986 error_log('404 Solution: admin page rendering failed: ' .
987 $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
988 }
989 $output = ob_get_clean();
990
991 if ($renderError !== null) {
992 echo '<div class="wrap">';
993 echo '<div class="notice notice-error">';
994 echo '<p><strong>404 Solution:</strong> An error occurred while rendering this page.</p>';
995 echo '<details><summary>Show error details</summary>';
996 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">' . esc_html($renderError->getMessage() . "\n" . $renderError->getTraceAsString()) . '</pre>';
997 echo '</details>';
998 echo '</div>';
999 echo '</div>';
1000 } elseif ($output === '' || $output === false) {
1001 // The View class was loaded and didn't throw, but produced zero output.
1002 // Show a diagnostic instead of a blank page.
1003 echo '<div class="wrap">';
1004 echo '<h1>404 Solution</h1>';
1005 echo '<div class="notice notice-error"><p>';
1006 echo '<strong>This page produced no output.</strong> ';
1007 echo 'This can happen when a required dependency failed to initialize or a template file is missing.';
1008 echo '</p><p>';
1009 echo 'Try deactivating and reactivating the plugin. If the problem persists, ';
1010 echo 'delete the plugin and reinstall it from the WordPress plugin directory.';
1011 echo '</p></div></div>';
1012 } else {
1013 echo $output;
1014 }
1015 } else {
1016 abj404_degraded_admin_page();
1017 }
1018 }
1019 }
1020
1021 // ----
1022 // get the plugin priority to use before adding the template_redirect action.
1023 $__abj404_options = abj404_get_settings_options();
1024 $__abj404_redirect_priority_raw = isset($__abj404_options['template_redirect_priority']) && is_scalar($__abj404_options['template_redirect_priority']) ? $__abj404_options['template_redirect_priority'] : 9;
1025 $__abj404_template_redirect_priority = absint($__abj404_redirect_priority_raw);
1026 $__abj404_redirect_all = isset($__abj404_options['redirect_all_requests']) && is_scalar($__abj404_options['redirect_all_requests']) ? (string)$__abj404_options['redirect_all_requests'] : '';
1027 $__abj404_update_suggest = isset($__abj404_options['update_suggest_url']) && is_scalar($__abj404_options['update_suggest_url']) ? (string)$__abj404_options['update_suggest_url'] : '';
1028 $GLOBALS['abj404_frontend_runtime_flags'] = array(
1029 'redirect_all_requests' => ($__abj404_redirect_all === '1'),
1030 'update_suggest_url' => ($__abj404_update_suggest === '1'),
1031 );
1032 $__abj404_lang_override = isset($__abj404_options['plugin_language_override']) && is_string($__abj404_options['plugin_language_override']) ? $__abj404_options['plugin_language_override'] : '';
1033 $GLOBALS['abj404_plugin_language_override'] = $__abj404_lang_override;
1034
1035 add_action('template_redirect', 'abj404_404listener', $__abj404_template_redirect_priority);
1036
1037 unset($__abj404_options);
1038 unset($__abj404_template_redirect_priority);
1039 abj404_benchmark_mark_bootstrap_done();
1040 // ---
1041
1042 // 404
1043 if (!function_exists('abj404_404listener')) {
1044 /** @return void */
1045 function abj404_404listener() {
1046 if (!$GLOBALS['abj404_boot_ok']) {
1047 return;
1048 }
1049 $is404 = is_404();
1050 if (!$is404) {
1051 // Performance: do NOT load the whole plugin on every frontend request unless we must.
1052 if (!empty($GLOBALS['abj404_frontend_runtime_flags']['redirect_all_requests'])) {
1053 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1054 $connector = ABJ_404_Solution_WordPress_Connector::getInstance();
1055 $connector->processRedirectAllRequests();
1056 return;
1057 }
1058
1059 $updateSuggestEnabled = !empty($GLOBALS['abj404_frontend_runtime_flags']['update_suggest_url']);
1060 $cookieName404 = ABJ404_PP . '_STATUS_404';
1061 $has404StatusCookie = (isset($_COOKIE[$cookieName404]) && $_COOKIE[$cookieName404] == 'true');
1062
1063 // Fast path: if none of the non-404 features are active, bail immediately.
1064 if (!$updateSuggestEnabled && !$has404StatusCookie) {
1065 return;
1066 }
1067
1068 /** If we're currently redirecting to a custom 404 page and we are about to show page
1069 * suggestions then update the URL displayed to the user. */
1070 $cookieName = ABJ404_PP . '_REQUEST_URI_UPDATE_URL';
1071 $queryParamName = ABJ404_PP . '_ref';
1072
1073 $hasUpdateCookie = !empty($_COOKIE[$cookieName]);
1074 $hasUpdateParam = !empty($_GET[$queryParamName]);
1075
1076 // Fast path: nothing pending from prior plugin-driven redirects.
1077 if (!$hasUpdateCookie && !$hasUpdateParam && !$has404StatusCookie) {
1078 return;
1079 }
1080
1081 if ($has404StatusCookie) {
1082 // clear the cookie
1083 setcookie($cookieName404, 'false', time() - 5, "/");
1084 // we're going to a custom 404 page so set the status to 404.
1085 status_header(404);
1086 }
1087
1088 if (!$updateSuggestEnabled) {
1089 return;
1090 }
1091
1092 // Check cookie first, then query param fallback (for 301 redirects where cookies don't survive)
1093 $originalURL = null;
1094 if ($hasUpdateCookie) {
1095 $originalURL = $_COOKIE[$cookieName];
1096 } elseif ($hasUpdateParam) {
1097 $originalURL = urldecode($_GET[$queryParamName]);
1098 }
1099
1100 if ($originalURL !== null) {
1101 // clear the cookie - sanitize before writing to $_REQUEST
1102 $sanitizedOriginal = sanitize_text_field($originalURL);
1103 $_REQUEST[ABJ404_PP . '_REQUEST_URI'] = $sanitizedOriginal;
1104 $_REQUEST[ABJ404_PP . '_REQUEST_URI_UPDATE_URL'] = $sanitizedOriginal;
1105 setcookie($cookieName, '', time() - 5, "/");
1106
1107 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1108 add_action('wp_head', 'ABJ_404_Solution_ShortCode::updateURLbarIfNecessary');
1109 }
1110 return;
1111 }
1112
1113 // ignore admin screens and login requests on 404 processing path.
1114 // $_SERVER['SCRIPT_NAME'] is not guaranteed (CLI, some test runners, some proxies).
1115 // Use a direct script-name check to avoid invoking wp_login_url() filters.
1116 $scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
1117 $requestUri = $_SERVER['REQUEST_URI'] ?? '';
1118 $isLoginScreen = (
1119 ($scriptName !== '' && stripos($scriptName, 'wp-login.php') !== false) ||
1120 ($requestUri !== '' && stripos($requestUri, 'wp-login.php') !== false)
1121 );
1122 if (is_admin() || $isLoginScreen) {
1123 return;
1124 }
1125
1126 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1127 $connector = ABJ_404_Solution_WordPress_Connector::getInstance();
1128 $connector->process404();
1129 }
1130 }
1131
1132 if (!function_exists('abj404_is_redirect_all_requests_enabled')) {
1133 /**
1134 * Small helper for testability and to keep option-parsing logic consistent.
1135 *
1136 * @param mixed $options Value returned by get_option('abj404_settings')
1137 * @return bool
1138 */
1139 function abj404_is_redirect_all_requests_enabled($options) {
1140 return is_array($options) &&
1141 array_key_exists('redirect_all_requests', $options) &&
1142 (string)$options['redirect_all_requests'] === '1';
1143 }
1144 }
1145
1146 if (!function_exists('abj404_dailyMaintenanceCronJobListener')) {
1147 /** @return void */
1148 function abj404_dailyMaintenanceCronJobListener() {
1149 try {
1150 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1151 $redirectsRepo = abj_service('redirects_repository');
1152 $redirectsRepo->deleteOldRedirectsCron();
1153
1154 $dbUpgrades = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1155 $dbUpgrades->runDatabaseMaintenanceTasks();
1156 } catch (\Throwable $e) {
1157 error_log('404 Solution cron (maintenance): ' . $e->getMessage());
1158 }
1159 }
1160 }
1161
1162 if (!function_exists('abj404_updateLogsHitsTableListener')) {
1163 /** @return void */
1164 function abj404_updateLogsHitsTableListener() {
1165 try {
1166 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1167 $logsRepo = abj_service('logs_repository');
1168 $logsRepo->createRedirectsForViewHitsTable();
1169 } catch (\Throwable $e) {
1170 error_log('404 Solution cron (logs/hits): ' . $e->getMessage());
1171 }
1172 }
1173 }
1174 if (!function_exists('abj404_logsv2CanonicalUrlBackfillListener')) {
1175 /** @return void */
1176 function abj404_logsv2CanonicalUrlBackfillListener() {
1177 try {
1178 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1179 $dbUpgrades = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1180 $dbUpgrades->backfillLogsv2CanonicalUrl();
1181 } catch (\Throwable $e) {
1182 error_log('404 Solution cron (log canonical URL backfill): ' . $e->getMessage());
1183 }
1184 }
1185 }
1186 if (!function_exists('abj404_updatePermalinkCacheListener')) {
1187 /**
1188 * @param int $maxExecutionTime
1189 * @param int $executionCount
1190 * @return void
1191 */
1192 function abj404_updatePermalinkCacheListener($maxExecutionTime, $executionCount = 1) {
1193 try {
1194 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1195 $permalinkCache = ABJ_404_Solution_PermalinkCache::getInstance();
1196 $permalinkCache->updatePermalinkCache($maxExecutionTime, $executionCount);
1197 } catch (\Throwable $e) {
1198 error_log('404 Solution cron (permalink cache): ' . $e->getMessage());
1199 }
1200 }
1201 }
1202 if (!function_exists('abj404_rebuildNGramCacheListener')) {
1203 /**
1204 * @param int $offset
1205 * @return void
1206 */
1207 function abj404_rebuildNGramCacheListener($offset = 0) {
1208 try {
1209 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1210 $dbUpgrades = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1211 $dbUpgrades->rebuildNGramCacheAsync($offset);
1212 } catch (\Throwable $e) {
1213 error_log('404 Solution cron (ngram cache): ' . $e->getMessage());
1214 }
1215 }
1216 }
1217 if (!function_exists('abj404_networkActivationListener')) {
1218 /** @return void */
1219 function abj404_networkActivationListener() {
1220 try {
1221 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1222 ABJ_404_Solution_PluginLogic::networkActivationCronHandler();
1223 } catch (\Throwable $e) {
1224 error_log('404 Solution cron (network activation): ' . $e->getMessage());
1225 }
1226 }
1227 }
1228 if (!function_exists('abj404_networkActivationBackgroundListener')) {
1229 /** @return void */
1230 function abj404_networkActivationBackgroundListener() {
1231 try {
1232 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1233 $upgradesEtc = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1234 $upgradesEtc->processMultisiteActivationBatch();
1235 } catch (\Throwable $e) {
1236 error_log('404 Solution cron (multisite activation): ' . $e->getMessage());
1237 }
1238 }
1239 }
1240 if (!function_exists('abj404_networkUpgradeBackgroundListener')) {
1241 /** @return void */
1242 function abj404_networkUpgradeBackgroundListener() {
1243 try {
1244 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1245 $upgradesEtc = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1246 $upgradesEtc->processMultisiteUpgradeBatch();
1247 } catch (\Throwable $e) {
1248 error_log('404 Solution cron (multisite upgrade): ' . $e->getMessage());
1249 }
1250 }
1251 }
1252 if (!function_exists('abj404_rebuildViewDoneListener')) {
1253 /** @return void */
1254 function abj404_rebuildViewDoneListener() {
1255 try {
1256 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1257 $viewBuild = abj_service('view_build_orchestrator');
1258 $viewBuild->rebuildViewDoneInBackground();
1259 } catch (\Throwable $e) {
1260 error_log('404 Solution cron (view table rebuild): ' . $e->getMessage());
1261 }
1262 }
1263 }
1264 add_action('abj404_cleanupCronAction', 'abj404_dailyMaintenanceCronJobListener');
1265 add_action('abj404_updateLogsHitsTableAction', 'abj404_updateLogsHitsTableListener');
1266 add_action('abj404_logsv2_canonical_backfill', 'abj404_logsv2CanonicalUrlBackfillListener');
1267 add_action('abj404_updatePermalinkCacheAction', 'abj404_updatePermalinkCacheListener', 10, 2);
1268 add_action('abj404_rebuildViewDone', 'abj404_rebuildViewDoneListener');
1269 add_action('abj404_send_digest', 'abj404_sendDigestCronListener');
1270 add_action('abj404_send_queued_report', 'abj404_sendQueuedReportListener', 10, 1);
1271 if (!function_exists('abj404_sendQueuedReportListener')) {
1272 /**
1273 * Cron handler for FeedbackTransport queued sends. Loads Loader.php so the
1274 * autoloader resolves ABJ_404_Solution_FeedbackTransport, then dispatches.
1275 *
1276 * @param string $uuid
1277 * @return void
1278 */
1279 function abj404_sendQueuedReportListener($uuid) {
1280 try {
1281 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1282 ABJ_404_Solution_FeedbackTransport::handleQueuedSend(is_string($uuid) ? $uuid : '');
1283 } catch (\Throwable $e) {
1284 error_log('404 Solution cron (feedback transport): ' . $e->getMessage());
1285 }
1286 }
1287 }
1288 if (!function_exists('abj404_sendDigestCronListener')) {
1289 /** @return void */
1290 function abj404_sendDigestCronListener() {
1291 try {
1292 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1293 $dao = ABJ_404_Solution_DataAccess::getInstance();
1294 $logger = ABJ_404_Solution_Logging::getInstance();
1295 $emailDigest = new ABJ_404_Solution_EmailDigest($dao, $logger);
1296 $emailDigest->onCronSendDigest();
1297 } catch (\Throwable $e) {
1298 error_log('404 Solution cron (email digest): ' . $e->getMessage());
1299 }
1300 }
1301 }
1302 add_action('abj404_rebuild_ngram_cache_hook', 'abj404_rebuildNGramCacheListener', 10, 1);
1303 add_action('abj404_network_activation_hook', 'abj404_networkActivationListener');
1304 add_action('abj404_network_activation_background', 'abj404_networkActivationBackgroundListener');
1305 add_action('abj404_network_upgrade_background', 'abj404_networkUpgradeBackgroundListener');
1306 add_action('abj404_gsc_fetch_cron', 'abj404_gscFetchCronListener');
1307 add_action('abj404_gsc_background_refresh', 'abj404_gscBackgroundRefreshListener');
1308
1309 if (!function_exists('abj404_gscFetchCronListener')) {
1310 /** Nightly cron: fetch GSC data and cache it. @return void */
1311 function abj404_gscFetchCronListener(): void {
1312 try {
1313 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1314 $gscLogger = ABJ_404_Solution_Logging::getInstance();
1315 $gsc = new ABJ_404_Solution_GoogleSearchConsole($gscLogger);
1316 $gsc->fetchAndCacheGscData();
1317 } catch (\Throwable $e) {
1318 error_log('404 Solution cron (GSC fetch): ' . $e->getMessage());
1319 }
1320 }
1321 }
1322
1323 if (!function_exists('abj404_gscBackgroundRefreshListener')) {
1324 /** On-demand background refresh triggered when an admin views the Options tab with stale data. @return void */
1325 function abj404_gscBackgroundRefreshListener(): void {
1326 try {
1327 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1328 $gscLogger = ABJ_404_Solution_Logging::getInstance();
1329 $gsc = new ABJ_404_Solution_GoogleSearchConsole($gscLogger);
1330 $gsc->fetchAndCacheGscData();
1331 } catch (\Throwable $e) {
1332 error_log('404 Solution cron (GSC background refresh): ' . $e->getMessage());
1333 }
1334 }
1335 }
1336
1337 /**
1338 * Override the locale for this plugin if user has configured a language override.
1339 * This allows users to use a different language for the 404 Solution plugin
1340 * than their WordPress site language or user language preference.
1341 *
1342 * @param string $locale The current locale.
1343 * @param string $domain The text domain.
1344 * @return string The locale to use for translation loading.
1345 */
1346 if (!function_exists('abj404_override_plugin_locale')) {
1347 /**
1348 * @param string $locale
1349 * @param string $domain
1350 * @return string
1351 */
1352 function abj404_override_plugin_locale($locale, $domain) {
1353 // Only override for our plugin's text domain.
1354 // Use the value cached in $GLOBALS at plugin boot to avoid a redundant get_option() call.
1355 if ($domain === '404-solution') {
1356 $override = isset($GLOBALS['abj404_plugin_language_override']) && is_string($GLOBALS['abj404_plugin_language_override']) ? $GLOBALS['abj404_plugin_language_override'] : '';
1357 if ($override !== '') {
1358 return $override;
1359 }
1360 }
1361 return $locale;
1362 }
1363 }
1364 add_filter('plugin_locale', 'abj404_override_plugin_locale', 999, 2);
1365
1366 if (!function_exists('abj404_show_runtime_integrity_notice')) {
1367 /** @return void */
1368 function abj404_show_runtime_integrity_notice() {
1369 if (!is_admin() || !current_user_can('manage_options')) {
1370 return;
1371 }
1372 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
1373 if ($page !== ABJ404_PP) {
1374 return;
1375 }
1376 $missing = get_transient('abj404_runtime_missing_files');
1377 if (!is_array($missing) || count($missing) === 0) {
1378 return;
1379 }
1380 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ';
1381 echo esc_html(__('Some required plugin files are missing. Please reinstall the plugin package.', '404-solution'));
1382 echo '</p><p><code>' . esc_html(implode(', ', array_map('basename', $missing))) . '</code></p></div>';
1383 }
1384 }
1385 add_action('admin_notices', 'abj404_show_runtime_integrity_notice');
1386
1387 if (!function_exists('abj404_show_plugin_db_notice')) {
1388 /** @return void */
1389 function abj404_show_plugin_db_notice() {
1390 if (!is_admin() || !current_user_can('manage_options')) {
1391 return;
1392 }
1393 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
1394 if ($page !== ABJ404_PP) {
1395 return;
1396 }
1397 $notice = get_transient('abj404_plugin_db_notice');
1398 if (!is_array($notice) || empty($notice['message'])) {
1399 return;
1400 }
1401 $type = isset($notice['type']) ? $notice['type'] : '';
1402 // Collation issues are developer-level; don't show them to the user.
1403 if ($type === 'collation') {
1404 return;
1405 }
1406 $guidance = '';
1407 if ($type === 'disk_full') {
1408 $guidance = __('Contact your hosting provider. This is usually caused by a database quota, tablespace limit, or full /tmp partition — not necessarily a full disk.', '404-solution');
1409 } elseif ($type === 'read_only') {
1410 $guidance = __('Your database is currently in read-only mode. Contact your hosting provider.', '404-solution');
1411 } elseif ($type === 'query_quota') {
1412 $guidance = __('Your database query quota was exceeded. This usually resets automatically.', '404-solution');
1413 } elseif ($type === 'corrupted_temp_table') {
1414 $guidance = __('A temporary MySQL table was corrupted, usually caused by disk or hardware issues. The plugin cannot repair it. Please contact your hosting provider.', '404-solution');
1415 } elseif ($type === 'log_table_full') {
1416 $guidance = __('The 404 Solution log table is full. The plugin automatically trimmed the oldest 1,000 log entries to free space, but logging may still be limited. Please contact your hosting provider about disk space.', '404-solution');
1417 } elseif ($type === 'stale_permalink_cache') {
1418 $guidance = __('The permalink cache appears to be empty. Try rebuilding it from the Tools tab, or check that your site has enough disk space.', '404-solution');
1419 } elseif ($type === 'lock_timeout') {
1420 $guidance = __('A database lock wait timeout occurred. This is usually caused by another process holding a table lock on your database. It may resolve itself automatically, or contact your hosting provider if it persists.', '404-solution');
1421 }
1422 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ' . esc_html($notice['message']) . '</p>';
1423 if ($guidance !== '') {
1424 echo '<p>' . esc_html($guidance) . '</p>';
1425 }
1426 if (!empty($notice['error_string'])) {
1427 echo '<details><summary>' . esc_html(__('Show database error details', '404-solution')) . '</summary>';
1428 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">' . esc_html($notice['error_string']) . '</pre></details>';
1429 }
1430 echo '</div>';
1431 }
1432 }
1433 add_action('admin_notices', 'abj404_show_plugin_db_notice');
1434
1435 if (!function_exists('abj404_show_view_build_cron_notices')) {
1436 /**
1437 * Render the staged-view-build cron-stuck and schedule-failure notices.
1438 * Set by DataAccess::scheduleViewDoneRebuild() when WordPress cron has
1439 * stopped advancing (earliest overdue ready-job >= 24h old) or when
1440 * wp_schedule_single_event itself fails. 24h dedup transients.
1441 *
1442 * @return void
1443 */
1444 function abj404_show_view_build_cron_notices() {
1445 if (!is_admin() || !current_user_can('manage_options')) {
1446 return;
1447 }
1448 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
1449 if ($page !== ABJ404_PP) {
1450 return;
1451 }
1452 $keys = array(
1453 'abj404_view_build_stuck_wp_cron_disabled',
1454 'abj404_view_build_cron_schedule_failed',
1455 'abj404_view_done_hard_stale',
1456 'abj404_logs_hits_rollup_stale',
1457 );
1458 if (class_exists('ABJ_404_Solution_ServiceContainer')
1459 && ABJ_404_Solution_ServiceContainer::safeHas('rebuild_health')) {
1460 $rebuildHealth = ABJ_404_Solution_ServiceContainer::safeGet('rebuild_health');
1461 if ($rebuildHealth instanceof ABJ_404_Solution_RebuildHealthState) {
1462 $payload = $rebuildHealth->getNoticePayload();
1463 if (is_array($payload)) {
1464 $count = isset($payload['failure_count']) ? (int)$payload['failure_count'] : 0;
1465 $class = isset($payload['last_failure_class']) && is_string($payload['last_failure_class'])
1466 ? $payload['last_failure_class']
1467 : 'unknown';
1468 $nextAllowed = isset($payload['next_allowed_at']) ? (int)$payload['next_allowed_at'] : 0;
1469 $seconds = max(0, $nextAllowed - time());
1470 echo '<div class="notice notice-warning"><p><strong>404 Solution:</strong> '
1471 . esc_html(sprintf(
1472 __('View/hits rebuild paused after %d consecutive failures (class: %s). Next retry in about %d minutes.', '404-solution'),
1473 $count,
1474 $class,
1475 (int)ceil($seconds / 60)
1476 )) . '</p>';
1477 if (!empty($payload['last_failure_msg']) && is_string($payload['last_failure_msg'])) {
1478 echo '<details><summary>' . esc_html(__('Show details', '404-solution'))
1479 . '</summary><pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">'
1480 . esc_html($payload['last_failure_msg']) . '</pre></details>';
1481 }
1482 $retryUrl = admin_url('admin.php?page=' . ABJ404_PP . '&subpage=abj404_redirects&abj404_force_view_rebuild=1');
1483 echo '<p><a class="button button-secondary" href="' . esc_url($retryUrl) . '">'
1484 . esc_html(__('Retry Now', '404-solution')) . '</a></p>';
1485 echo '</div>';
1486 }
1487 }
1488 }
1489 foreach ($keys as $key) {
1490 $notice = get_transient($key);
1491 if (!is_array($notice) || empty($notice['message'])) {
1492 continue;
1493 }
1494 $noticeMessage = is_string($notice['message']) ? $notice['message'] : '';
1495 echo '<div class="notice notice-warning"><p><strong>404 Solution:</strong> '
1496 . esc_html($noticeMessage) . '</p>';
1497 if (!empty($notice['error_string'])) {
1498 $noticeErrorString = is_string($notice['error_string']) ? $notice['error_string'] : '';
1499 echo '<details><summary>' . esc_html(__('Show details', '404-solution'))
1500 . '</summary><pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">'
1501 . esc_html($noticeErrorString) . '</pre></details>';
1502 }
1503 echo '</div>';
1504 }
1505 }
1506 }
1507 add_action('admin_notices', 'abj404_show_view_build_cron_notices');
1508
1509 if (!function_exists('abj404_get_simulated_db_latency_ms')) {
1510 /** @return bool */
1511 function abj404_is_local_debug_host() {
1512 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '');
1513 $serverName = strtolower(trim((string)$serverName));
1514 if ($serverName === '') {
1515 return false;
1516 }
1517
1518 $normalizedHost = $serverName;
1519 if (strpos($normalizedHost, '[') === 0) {
1520 $endBracket = strpos($normalizedHost, ']');
1521 if ($endBracket !== false) {
1522 $normalizedHost = substr($normalizedHost, 1, $endBracket - 1);
1523 }
1524 } else {
1525 $colonCount = substr_count($normalizedHost, ':');
1526 if ($colonCount === 1 && preg_match('/:\d+$/', $normalizedHost)) {
1527 $normalizedHost = preg_replace('/:\d+$/', '', $normalizedHost);
1528 }
1529 }
1530
1531 $normalizedHost = rtrim((string)$normalizedHost, '.');
1532 return in_array($normalizedHost, array('127.0.0.1', '::1', 'localhost'), true);
1533 }
1534
1535 /** @return int */
1536 function abj404_get_simulated_db_latency_ms() {
1537 if (!abj404_is_local_debug_host()) {
1538 return 0;
1539 }
1540 if (defined('ABJ404_SIMULATED_DB_LATENCY_MS')) {
1541 return max(0, min(5000, absint(ABJ404_SIMULATED_DB_LATENCY_MS)));
1542 }
1543 $value = get_option('abj404_simulated_db_latency_ms', 0);
1544 return max(0, min(5000, absint(is_scalar($value) ? $value : 0)));
1545 }
1546 }
1547
1548 if (!function_exists('abj404_show_diagnostic_latency_notice')) {
1549 /** @return void */
1550 function abj404_show_diagnostic_latency_notice() {
1551 // Intentionally no-op. Simulated latency status is shown in the plugin's
1552 // Tools > Diagnostics card to avoid intrusive floating/global notices.
1553 return;
1554 }
1555 }
1556
1557 if (!function_exists('abj404_load_textdomain_if_needed')) {
1558 /**
1559 * Load plugin translations once, lazily.
1560 *
1561 * @return void
1562 */
1563 function abj404_load_textdomain_if_needed() {
1564 static $loaded = false;
1565 if ($loaded) {
1566 return;
1567 }
1568
1569 $override_locale = '';
1570 if (!empty($GLOBALS['abj404_plugin_language_override'])) {
1571 $override_locale = (string)$GLOBALS['abj404_plugin_language_override'];
1572 } else {
1573 $options = abj404_get_settings_options();
1574 $override_locale = (is_array($options) && !empty($options['plugin_language_override']))
1575 ? $options['plugin_language_override'] : '';
1576 }
1577
1578 if (!empty($override_locale)) {
1579 $mo_file = ABJ404_PATH . 'languages/404-solution-' . $override_locale . '.mo';
1580 if (file_exists($mo_file)) {
1581 load_textdomain('404-solution', $mo_file);
1582 }
1583 } else {
1584 $lang_dir = dirname(plugin_basename(ABJ404_FILE)) . '/languages';
1585 load_plugin_textdomain('404-solution', false, $lang_dir);
1586 }
1587
1588 $loaded = true;
1589 }
1590 }
1591
1592 if (!function_exists('abj404_maybe_refresh_runtime_integrity_cache')) {
1593 /**
1594 * Refresh runtime integrity cache at most once per TTL window.
1595 *
1596 * @param int $ttlSeconds
1597 * @return void
1598 */
1599 function abj404_maybe_refresh_runtime_integrity_cache($ttlSeconds = 43200) {
1600 if (!is_admin()) {
1601 return;
1602 }
1603
1604 $checkedRecently = get_transient('abj404_runtime_integrity_checked');
1605 if ($checkedRecently) {
1606 return;
1607 }
1608
1609 $missingRuntimeFiles = abj404_verify_runtime_integrity();
1610 if (count($missingRuntimeFiles) > 0) {
1611 set_transient('abj404_runtime_missing_files', $missingRuntimeFiles, $ttlSeconds);
1612 } else {
1613 delete_transient('abj404_runtime_missing_files');
1614 }
1615
1616 set_transient('abj404_runtime_integrity_checked', 1, $ttlSeconds);
1617 }
1618 }
1619
1620 /** This only runs after WordPress is done enqueuing scripts. */
1621 if (!function_exists('abj404_loadSomethingWhenWordPressIsReady')) {
1622 /** @return void */
1623 function abj404_loadSomethingWhenWordPressIsReady() {
1624 // If boot failed (missing files), skip all init that depends on plugin classes.
1625 if (!$GLOBALS['abj404_boot_ok']) {
1626 return;
1627 }
1628
1629 $isAdminRequest = is_admin();
1630 if ($isAdminRequest) {
1631 abj404_load_textdomain_if_needed();
1632 }
1633
1634 // make debugging easier on localhost etc
1635 if ($isAdminRequest) {
1636 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
1637 $serverNameIsInTheWhiteList = in_array($serverName, $GLOBALS['abj404_whitelist']);
1638
1639 // Keep localhost debug helper on admin screens only; frontend requests stay lean.
1640 if ($serverNameIsInTheWhiteList && function_exists('wp_get_current_user')) {
1641 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1642 $abj404logic = ABJ_404_Solution_PluginLogic::getInstance();
1643 if ($abj404logic->userIsPluginAdmin()) {
1644 $GLOBALS['abj404_display_errors'] = true;
1645 }
1646 }
1647 }
1648
1649 $action = null;
1650 if ($isAdminRequest) {
1651 $actionGet = isset($_GET['action']) && is_string($_GET['action']) ? $_GET['action'] : '';
1652 $actionPost = isset($_POST['action']) && is_string($_POST['action']) ? $_POST['action'] : '';
1653 if ($actionGet !== '') {
1654 $action = sanitize_text_field($actionGet);
1655 } else if ($actionPost !== '') {
1656 $action = sanitize_text_field($actionPost);
1657 } else {
1658 $action = null;
1659 }
1660 }
1661 if ($isAdminRequest && abj404_is_local_debug_host() && current_user_can('manage_options') && isset($_GET['abj404_set_sim_db_ms'])) {
1662 $nonceOk = isset($_GET['_wpnonce']) ? wp_verify_nonce($_GET['_wpnonce'], 'abj404_set_sim_db_ms') : false;
1663 if ($nonceOk) {
1664 $newMs = max(0, min(5000, absint($_GET['abj404_set_sim_db_ms'])));
1665 update_option('abj404_simulated_db_latency_ms', $newMs, false);
1666 }
1667 }
1668
1669 $ttl = defined('HOUR_IN_SECONDS') ? (12 * HOUR_IN_SECONDS) : 43200;
1670 abj404_maybe_refresh_runtime_integrity_cache($ttl);
1671
1672 if ($isAdminRequest && $action === 'exportRedirects') {
1673 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1674 $abj404logic = ABJ_404_Solution_PluginLogic::getInstance();
1675 $abj404logic->handleActionExport();
1676 }
1677 }
1678 }
1679 add_action('admin_init', 'abj404_loadSomethingWhenWordPressIsReady');
1680
1681 if (!function_exists('abj404_maybePageLoadFallbackAdvance')) {
1682 /**
1683 * Admin-only, plugin-page-only synchronous fallback that advances the
1684 * staged view-build by one tick (about 2s) when WP-Cron is broken.
1685 *
1686 * Pairs with the cron-stuck admin notice (c374): the notice tells the
1687 * admin their cron is broken; this fallback unblocks the page in the
1688 * meantime so they can fix cron without staring at the loading
1689 * indicator forever. The actual gate logic and budget compression live
1690 * in ABJ_404_Solution_DataAccess::runPageLoadFallbackAdvance() so they
1691 * can be unit-tested directly; this wrapper is the admin_init hook
1692 * that wires the DAO method into the request lifecycle.
1693 *
1694 * Guards (in order, all required):
1695 * - boot succeeded (plugin class loadable);
1696 * - is_admin() (frontend / REST / heartbeat requests are not in scope);
1697 * - not AJAX or cron (those have their own advance paths);
1698 * - request is for the plugin admin page (abj404_solution); other
1699 * wp-admin pages are unrelated and should not be taxed with build
1700 * work on every navigation;
1701 * - current user has the plugin admin capability (manage_options) so
1702 * an unauthenticated request cannot trigger build work;
1703 * - DataAccess exposes runPageLoadFallbackAdvance (defense for older
1704 * in-place upgrades whose DAO class predates this method).
1705 *
1706 * The DAO method itself owns the cron-stuck check, the transient gate,
1707 * the per-stage budget compression, and the build-lock semantics.
1708 *
1709 * @return void
1710 */
1711 function abj404_maybePageLoadFallbackAdvance() {
1712 if (!$GLOBALS['abj404_boot_ok']) {
1713 return;
1714 }
1715 if (!is_admin()) {
1716 return;
1717 }
1718 if (function_exists('wp_doing_ajax') && wp_doing_ajax()) {
1719 return;
1720 }
1721 if (function_exists('wp_doing_cron') && wp_doing_cron()) {
1722 return;
1723 }
1724 $currentPage = isset($_GET['page']) && is_string($_GET['page'])
1725 ? sanitize_text_field((string)$_GET['page']) : '';
1726 if ($currentPage !== 'abj404_solution') {
1727 return;
1728 }
1729 if (!function_exists('current_user_can') || !current_user_can('manage_options')) {
1730 return;
1731 }
1732 try {
1733 require_once(plugin_dir_path(__FILE__) . "includes/Loader.php");
1734 $dao = ABJ_404_Solution_DataAccess::getInstance();
1735 if (is_object($dao) && method_exists($dao, 'runPageLoadFallbackAdvance')) {
1736 $dao->runPageLoadFallbackAdvance();
1737 }
1738 } catch (\Throwable $e) {
1739 // Page-load fallback is best-effort. A failure here must not
1740 // break admin page rendering. Log at warning level (error_log
1741 // suffices for this surface) so the failure is observable
1742 // without triggering the plugin's dev-email-report path. Per
1743 // CLAUDE.md self-healing rule #6: infrastructure failures are
1744 // warnings, not errors, when the plugin still functions.
1745 error_log('404 Solution: page-load fallback advance failed: ' . $e->getMessage());
1746 }
1747 }
1748 }
1749 // Priority 20 runs after abj404_loadSomethingWhenWordPressIsReady (default
1750 // priority 10), so the textdomain is loaded and any pending exportRedirects
1751 // has run before we burn ~2s of stage budget. Inverting that order would
1752 // risk an export action being preceded by inline staged-build work, which
1753 // changes the apparent latency of the export.
1754 add_action('admin_init', 'abj404_maybePageLoadFallbackAdvance', 20);
1755