PluginProbe
404 Solution / 4.1.18
404 Solution v4.1.18
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.18, at 404-solution.php

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