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 / 404-solution.php

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

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