PluginProbe
404 Solution / 4.1.13
404 Solution v4.1.13
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.13, at 404-solution.php

1,346 lines 50.8 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.13
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 /**
99 * @param string $class
100 * @return void
101 */
102 function abj404_autoloader($class) {
103 // some people were having issues with possibly parent classes not being loaded before their children.
104 $childParentMap = [
105 'ABJ_404_Solution_FunctionsMBString' => 'ABJ_404_Solution_Functions',
106 'ABJ_404_Solution_FunctionsPreg' => 'ABJ_404_Solution_Functions',
107 ];
108
109 // only pay attention if it's for us. don't bother for other things.
110 if (substr($class, 0, 16) !== 'ABJ_404_Solution') {
111 return;
112 }
113
114 // Use a deterministic classmap to avoid runtime glob() scans on real sites.
115 static $abj404_autoLoaderClassMap = null;
116 if ($abj404_autoLoaderClassMap === null) {
117 $mapFile = __DIR__ . '/includes/classmap.php';
118 $abj404_autoLoaderClassMap = file_exists($mapFile) ? require $mapFile : array();
119 }
120
121 if (!array_key_exists($class, $abj404_autoLoaderClassMap)) {
122 return;
123 }
124
125 // Trait dependency pre-check: classes that use require_once for trait files at file
126 // scope will cause an uncatchable compile-time fatal if any trait file is missing.
127 // Verify all trait files exist BEFORE loading the parent class.
128 static $traitDependencies = null;
129 if ($traitDependencies === null) {
130 // Use __DIR__ (not ABJ404_PATH) to match the classmap's path resolution.
131 $inc = __DIR__ . '/includes/';
132 $traitDependencies = array(
133 'ABJ_404_Solution_View' => array(
134 $inc . 'ViewTrait_Shared.php',
135 $inc . 'ViewTrait_UI.php',
136 $inc . 'ViewTrait_Stats.php',
137 $inc . 'ViewTrait_Settings.php',
138 $inc . 'ViewTrait_Redirects.php',
139 $inc . 'ViewTrait_RedirectsTable.php',
140 $inc . 'ViewTrait_Logs.php',
141 ),
142 'ABJ_404_Solution_DataAccess' => array(
143 $inc . 'DataAccessTrait_Maintenance.php',
144 $inc . 'DataAccessTrait_ViewQueries.php',
145 $inc . 'DataAccessTrait_Logs.php',
146 $inc . 'DataAccessTrait_Redirects.php',
147 $inc . 'DataAccessTrait_Stats.php',
148 $inc . 'DataAccessTrait_ErrorClassification.php',
149 ),
150 'ABJ_404_Solution_PluginLogic' => array(
151 $inc . 'PluginLogicTrait_UrlNormalization.php',
152 $inc . 'PluginLogicTrait_AdminActions.php',
153 $inc . 'PluginLogicTrait_ImportExport.php',
154 $inc . 'PluginLogicTrait_SettingsUpdate.php',
155 $inc . 'PluginLogicTrait_PageOrdering.php',
156 $inc . 'PluginLogicTrait_Lifecycle.php',
157 ),
158 'ABJ_404_Solution_SpellChecker' => array(
159 $inc . 'SpellCheckerTrait_PostListeners.php',
160 $inc . 'SpellCheckerTrait_URLMatching.php',
161 $inc . 'SpellCheckerTrait_CandidateFiltering.php',
162 $inc . 'SpellCheckerTrait_LevenshteinEngine.php',
163 ),
164 'ABJ_404_Solution_DatabaseUpgradesEtc' => array(
165 $inc . 'DatabaseUpgradesEtcTrait_NGram.php',
166 $inc . 'DatabaseUpgradesEtcTrait_Maintenance.php',
167 $inc . 'DatabaseUpgradesEtcTrait_PluginUpdate.php',
168 ),
169 );
170 }
171
172 if (isset($traitDependencies[$class])) {
173 foreach ($traitDependencies[$class] as $traitFile) {
174 if (!file_exists($traitFile)) {
175 $GLOBALS['abj404_missing_files'][] = $traitFile;
176 // Don't load the parent class — the compile-time fatal is uncatchable.
177 return;
178 }
179 }
180 }
181
182 // Ensure the parent class is loaded first.
183 if (array_key_exists($class, $childParentMap)) {
184 $parentClass = $childParentMap[$class];
185 if (!class_exists($parentClass, false) && array_key_exists($parentClass, $abj404_autoLoaderClassMap)) {
186 $parentFile = $abj404_autoLoaderClassMap[$parentClass];
187 if (!file_exists($parentFile)) {
188 $GLOBALS['abj404_missing_files'][] = $parentFile;
189 return;
190 }
191 require_once $parentFile;
192 }
193 }
194
195 $classFile = $abj404_autoLoaderClassMap[$class];
196 if (!file_exists($classFile)) {
197 $GLOBALS['abj404_missing_files'][] = $classFile;
198 return;
199 }
200
201 require_once $classFile;
202 }
203 spl_autoload_register('abj404_autoloader');
204
205
206 add_action('doing_it_wrong_run', function($function_name, $message, $version) {
207 if (strpos($message, '404-solution') !== false &&
208 $function_name == '_load_textdomain_just_in_time') {
209
210 try {
211 $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
212
213 // Prepare the plugin path from ABJ404_FILE
214 $pluginPath = trailingslashit(plugin_dir_path(ABJ404_FILE)); // e.g., /var/www/html/wp-content/plugins/404-solution/
215
216 $logMessage = '';
217 $isOurPlugin = false;
218
219 foreach ($backtrace as $index => $frame) {
220 $file = isset($frame['file']) ? $frame['file'] : '[internal function]';
221 $line = isset($frame['line']) ? $frame['line'] : '';
222 $func = $frame['function'];
223
224 if (!$isOurPlugin && is_string($file) && strpos($file, $pluginPath) !== false) {
225 $isOurPlugin = true;
226 }
227
228 $logMessage .= "#$index $func at [$file:$line]\n";
229 }
230
231 if ($isOurPlugin) {
232 $header = "=== Detected Early Translation ===\n" .
233 "Function: $function_name\n" .
234 "Message: $message\n" .
235 "Version: $version\n";
236
237 if (!isset($GLOBALS['abj404_pending_errors'])) {
238 $GLOBALS['abj404_pending_errors'] = [];
239 }
240 $GLOBALS['abj404_pending_errors'][] = $header . $logMessage;
241 }
242
243 } catch (Throwable $e) {
244 error_log('404 Solution: failed to capture early translation stack trace: ' .
245 $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
246 }
247 }
248 }, 10, 3);
249
250 // shortcode
251 add_shortcode(ABJ404_SHORTCODE_NAME, 'abj404_shortCodeListener');
252 if (!function_exists('abj404_shortCodeListener')) {
253 /**
254 * @param array<string, mixed>|string $atts
255 * @return string
256 */
257 function abj404_shortCodeListener($atts) {
258 if (!$GLOBALS['abj404_boot_ok']) {
259 return '';
260 }
261 abj404_load_textdomain_if_needed();
262 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
263 /** @var array<string, mixed> $safeAtts */
264 $safeAtts = is_array($atts) ? $atts : array();
265 return ABJ_404_Solution_ShortCode::shortcodePageSuggestions($safeAtts);
266 }
267
268 if (!function_exists('abj404_get_required_runtime_files')) {
269 /**
270 * Files required for a healthy runtime/plugin package.
271 * Covers boot-critical PHP files and essential SQL templates.
272 *
273 * @return array<int, string>
274 */
275 function abj404_get_required_runtime_files() {
276 $inc = ABJ404_PATH . 'includes/';
277 return array(
278 // Boot-critical
279 $inc . 'Loader.php',
280 $inc . 'bootstrap.php',
281 $inc . 'classmap.php',
282 $inc . 'ServiceContainer.php',
283 $inc . 'Clock.php',
284 $inc . 'ErrorHandler.php',
285 // Core classes
286 $inc . 'WordPress_Connector.php',
287 $inc . 'Functions.php',
288 $inc . 'Logging.php',
289 $inc . 'FrontendRequestPipeline.php',
290 $inc . 'ImportExportService.php',
291 $inc . 'QueryBudgetInstrumentation.php',
292 // View + traits
293 $inc . 'View.php',
294 $inc . 'ViewTrait_Shared.php',
295 $inc . 'ViewTrait_UI.php',
296 $inc . 'ViewTrait_Stats.php',
297 $inc . 'ViewTrait_Settings.php',
298 $inc . 'ViewTrait_Redirects.php',
299 $inc . 'ViewTrait_RedirectsTable.php',
300 $inc . 'ViewTrait_Logs.php',
301 // DataAccess + traits
302 $inc . 'DataAccess.php',
303 $inc . 'DataAccessTrait_Maintenance.php',
304 $inc . 'DataAccessTrait_ViewQueries.php',
305 $inc . 'DataAccessTrait_Logs.php',
306 $inc . 'DataAccessTrait_Redirects.php',
307 $inc . 'DataAccessTrait_Stats.php',
308 // PluginLogic + traits
309 $inc . 'PluginLogic.php',
310 $inc . 'PluginLogicTrait_UrlNormalization.php',
311 $inc . 'PluginLogicTrait_AdminActions.php',
312 $inc . 'PluginLogicTrait_ImportExport.php',
313 $inc . 'PluginLogicTrait_SettingsUpdate.php',
314 $inc . 'PluginLogicTrait_PageOrdering.php',
315 $inc . 'PluginLogicTrait_Lifecycle.php',
316 // SpellChecker + traits
317 $inc . 'SpellChecker.php',
318 $inc . 'SpellCheckerTrait_PostListeners.php',
319 $inc . 'SpellCheckerTrait_URLMatching.php',
320 $inc . 'SpellCheckerTrait_CandidateFiltering.php',
321 $inc . 'SpellCheckerTrait_LevenshteinEngine.php',
322 // DatabaseUpgradesEtc + traits
323 $inc . 'DatabaseUpgradesEtc.php',
324 $inc . 'DatabaseUpgradesEtcTrait_NGram.php',
325 $inc . 'DatabaseUpgradesEtcTrait_Maintenance.php',
326 $inc . 'DatabaseUpgradesEtcTrait_PluginUpdate.php',
327 // SQL templates — all files required for correct operation.
328 // A test (SqlFileIntegrityListCompletenessTest) verifies this list
329 // stays in sync with the actual files in includes/sql/.
330 $inc . 'sql/correctLookupTableIssue.sql',
331 $inc . 'sql/createEngineProfilesTable.sql',
332 $inc . 'sql/createLogTable.sql',
333 $inc . 'sql/createLogsHitsPreAggTempTable.sql',
334 $inc . 'sql/createLogsHitsTempTable.sql',
335 $inc . 'sql/createLookupTable.sql',
336 $inc . 'sql/createNGramCacheTable.sql',
337 $inc . 'sql/createPermalinkCacheTable.sql',
338 $inc . 'sql/createRedirectConditionsTable.sql',
339 $inc . 'sql/createRedirectsTable.sql',
340 $inc . 'sql/createSpellingCacheTable.sql',
341 $inc . 'sql/createViewCacheTable.sql',
342 $inc . 'sql/deleteOldLogs.sql',
343 $inc . 'sql/getAdditionalPostData.sql',
344 $inc . 'sql/getIDsNeededForPermalinkCache.sql',
345 $inc . 'sql/getLogRecords.sql',
346 $inc . 'sql/getLogsCount.sql',
347 $inc . 'sql/getDistinctLoggedUrls.sql',
348 $inc . 'sql/getLogsIDandURL.sql',
349 $inc . 'sql/getLogsIDandURLForAjax.sql',
350 $inc . 'sql/getMostUnusedRedirects.sql',
351 $inc . 'sql/getOrphanedAutoRedirects.sql',
352 $inc . 'sql/getPermalinkFromURL.sql',
353 $inc . 'sql/getPostsNeedingContentKeywords.sql',
354 $inc . 'sql/getPublishedCategories.sql',
355 $inc . 'sql/getPublishedImageIDs.sql',
356 $inc . 'sql/getPublishedPagesAndPostsIDs.sql',
357 $inc . 'sql/getPublishedTags.sql',
358 $inc . 'sql/getRedirectsExport.sql',
359 $inc . 'sql/getRedirectsForView.sql',
360 $inc . 'sql/getRedirectsForViewTempTable.sql',
361 $inc . 'sql/getRedirectsWithLogs.sql',
362 $inc . 'sql/importDataFromPluginRedirectioner.sql',
363 $inc . 'sql/insertPermalinkCache.sql',
364 $inc . 'sql/insertSpellingCache.sql',
365 $inc . 'sql/logsSetMinLogID.sql',
366 $inc . 'sql/migrateToNewLogsTable.sql',
367 $inc . 'sql/selectTableEngines.sql',
368 $inc . 'sql/updatePermalinkCache.sql',
369 $inc . 'sql/updatePermalinkCacheParentPages.sql',
370 );
371 }
372 }
373
374 if (!function_exists('abj404_verify_runtime_integrity')) {
375 /**
376 * Validate that required plugin files are present.
377 *
378 * @return array<int, string> Missing file paths.
379 */
380 function abj404_verify_runtime_integrity() {
381 $missing = array();
382 foreach (abj404_get_required_runtime_files() as $path) {
383 if (!file_exists($path)) {
384 $missing[] = $path;
385 }
386 }
387 return $missing;
388 }
389 }
390
391 if (!function_exists('abj404_is_benchmark_request')) {
392 /**
393 * Benchmark instrumentation is disabled by default and only enabled per-request.
394 *
395 * @return bool
396 */
397 function abj404_is_benchmark_request() {
398 return isset($_GET['abj404_bench']) && (string)$_GET['abj404_bench'] === '1';
399 }
400 }
401
402 if (!function_exists('abj404_benchmark_bootstrap_start')) {
403 /** @return void */
404 function abj404_benchmark_bootstrap_start() {
405 if (!abj404_is_benchmark_request()) {
406 return;
407 }
408 if (!isset($GLOBALS['abj404_benchmark_state']) || !is_array($GLOBALS['abj404_benchmark_state'])) {
409 $GLOBALS['abj404_benchmark_state'] = array(
410 'start' => microtime(true),
411 'bootstrap_done' => 0.0,
412 'db_query_count' => 0,
413 'db_query_ms' => 0.0,
414 'redirect_lookup_ms' => 0.0,
415 );
416 }
417 }
418 }
419
420 if (!function_exists('abj404_benchmark_mark_bootstrap_done')) {
421 /** @return void */
422 function abj404_benchmark_mark_bootstrap_done() {
423 if (!abj404_is_benchmark_request() || !isset($GLOBALS['abj404_benchmark_state'])) {
424 return;
425 }
426 $GLOBALS['abj404_benchmark_state']['bootstrap_done'] = microtime(true);
427 }
428 }
429
430 if (!function_exists('abj404_benchmark_record_db_query')) {
431 /**
432 * @param float $elapsedMs
433 * @return void
434 */
435 function abj404_benchmark_record_db_query($elapsedMs) {
436 if (!abj404_is_benchmark_request() || !isset($GLOBALS['abj404_benchmark_state'])) {
437 return;
438 }
439 $elapsedMs = max(0.0, (float)$elapsedMs);
440 $GLOBALS['abj404_benchmark_state']['db_query_count']++;
441 $GLOBALS['abj404_benchmark_state']['db_query_ms'] += $elapsedMs;
442 }
443 }
444
445 if (!function_exists('abj404_benchmark_record_redirect_lookup')) {
446 /**
447 * @param float $elapsedMs
448 * @return void
449 */
450 function abj404_benchmark_record_redirect_lookup($elapsedMs) {
451 if (!abj404_is_benchmark_request() || !isset($GLOBALS['abj404_benchmark_state'])) {
452 return;
453 }
454 $GLOBALS['abj404_benchmark_state']['redirect_lookup_ms'] += max(0.0, (float)$elapsedMs);
455 }
456 }
457
458 if (!function_exists('abj404_benchmark_emit_headers')) {
459 /** @return void */
460 function abj404_benchmark_emit_headers() {
461 if (!abj404_is_benchmark_request() || headers_sent() || !isset($GLOBALS['abj404_benchmark_state'])) {
462 return;
463 }
464 $state = $GLOBALS['abj404_benchmark_state'];
465 $start = isset($state['start']) ? (float)$state['start'] : 0.0;
466 $bootstrapDone = isset($state['bootstrap_done']) ? (float)$state['bootstrap_done'] : 0.0;
467 $now = microtime(true);
468 $totalMs = ($start > 0.0) ? (($now - $start) * 1000.0) : 0.0;
469 $bootstrapMs = ($start > 0.0 && $bootstrapDone > 0.0) ? (($bootstrapDone - $start) * 1000.0) : 0.0;
470 $dbQueryCount = isset($state['db_query_count']) ? (int)$state['db_query_count'] : 0;
471 $dbQueryMs = isset($state['db_query_ms']) ? (float)$state['db_query_ms'] : 0.0;
472 $redirectLookupMs = isset($state['redirect_lookup_ms']) ? (float)$state['redirect_lookup_ms'] : 0.0;
473
474 header(
475 'X-ABJ404-Benchmark: ' .
476 'total_ms=' . round($totalMs, 3) . ';' .
477 'bootstrap_ms=' . round($bootstrapMs, 3) . ';' .
478 'db_query_count=' . $dbQueryCount . ';' .
479 'db_query_ms=' . round($dbQueryMs, 3) . ';' .
480 'redirect_lookup_ms=' . round($redirectLookupMs, 3)
481 );
482 }
483 }
484
485 abj404_benchmark_bootstrap_start();
486 if (abj404_is_benchmark_request()) {
487 add_action('send_headers', 'abj404_benchmark_emit_headers', PHP_INT_MAX);
488 }
489 }
490
491 // Minimal shutdown handler: catches compile/parse fatals in plugin files and
492 // stores them in a transient so the degraded admin page can display the error
493 // on the next request. This is important for PHP 7.4 where syntax errors in
494 // required files produce uncatchable E_COMPILE_ERROR.
495 if (!function_exists('abj404_boot_shutdown_handler')) {
496 /** @return void */
497 function abj404_boot_shutdown_handler() {
498 if ($GLOBALS['abj404_boot_ok']) {
499 return;
500 }
501 $error = error_get_last();
502 if ($error === null) {
503 return;
504 }
505 // Only capture fatal/compile errors in our plugin files.
506 $fatalTypes = E_ERROR | E_PARSE | E_COMPILE_ERROR | E_CORE_ERROR;
507 if (!($error['type'] & $fatalTypes)) {
508 return;
509 }
510 $pluginDir = defined('ABJ404_PATH') ? ABJ404_PATH : __DIR__ . '/';
511 if (strpos($error['file'], $pluginDir) === false) {
512 return;
513 }
514 $errorInfo = array(
515 'message' => $error['message'],
516 'file' => $error['file'],
517 'line' => $error['line'],
518 'type' => $error['type'],
519 'time' => time(),
520 );
521 // Use update_option as a fallback — set_transient might not be available
522 // during a fatal shutdown.
523 if (function_exists('set_transient')) {
524 set_transient('abj404_boot_fatal', $errorInfo, 3600);
525 }
526 }
527 }
528 register_shutdown_function('abj404_boot_shutdown_handler');
529
530 // Always load Loader.php to ensure plugin constants (ABJ404_TYPE_404_DISPLAYED,
531 // ABJ404_STATUS_MANUAL, etc.) are defined in all contexts: admin, REST API, WP-CLI
532 // eval, and template_redirect. Without this, direct calls to plugin classes via
533 // wp eval fail with "Undefined constant" errors because Loader.php was previously
534 // only loaded inside is_admin() — leaving WP-CLI and other non-admin contexts
535 // without the constants they need.
536 $__abj404_loader_path = plugin_dir_path( __FILE__ ) . "includes/Loader.php";
537 if (file_exists($__abj404_loader_path)) {
538 try {
539 require_once($__abj404_loader_path);
540 $GLOBALS['abj404_boot_ok'] = true;
541 // Clear any stale boot fatal transient from a previous failed load.
542 if (function_exists('delete_transient')) {
543 delete_transient('abj404_boot_fatal');
544 }
545 } catch (\Throwable $e) {
546 $GLOBALS['abj404_boot_ok'] = false;
547 $GLOBALS['abj404_boot_error'] = $e->getMessage();
548 error_log('404 Solution: boot failed — ' . $e->getMessage());
549 }
550 } else {
551 $GLOBALS['abj404_boot_ok'] = false;
552 $GLOBALS['abj404_missing_files'][] = $__abj404_loader_path;
553 $GLOBALS['abj404_boot_error'] = 'Loader.php is missing.';
554 }
555 unset($__abj404_loader_path);
556
557 if ($GLOBALS['abj404_boot_ok']) {
558 // admin
559 if (is_admin()) {
560 try {
561 ABJ_404_Solution_WordPress_Connector::init();
562 ABJ_404_Solution_ViewUpdater::init();
563 } catch (\Throwable $e) {
564 // init() failed — fall through to register the degraded admin page
565 // so the user still has a menu item with error details instead of nothing.
566 $GLOBALS['abj404_boot_ok'] = false;
567 $GLOBALS['abj404_boot_error'] = 'Plugin initialization failed: ' . $e->getMessage();
568 error_log('404 Solution: admin initialization failed: ' .
569 $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
570 add_action('admin_menu', 'abj404_degraded_admin_menu');
571 add_action('admin_notices', 'abj404_degraded_admin_notice');
572 }
573 }
574
575 // REST API — deferred to rest_api_init so DataAccess/PluginLogic are only loaded on actual REST requests.
576 add_action('rest_api_init', function() {
577 $dao = ABJ_404_Solution_DataAccess::getInstance();
578 $logic = ABJ_404_Solution_PluginLogic::getInstance();
579 $restController = new ABJ_404_Solution_RestApiController($dao, $logic);
580 $restController->registerRoutes();
581 });
582
583 // WP-CLI commands.
584 if (defined('WP_CLI') && WP_CLI) {
585 add_action('init', function() {
586 \WP_CLI::add_command('abj404', 'ABJ_404_Solution_WPCLICommands');
587 }, 1);
588 }
589 } elseif (function_exists('is_admin') && is_admin()) {
590 // Boot failed — register degraded admin page so the admin sees instructions
591 // instead of a white screen or missing menu item.
592 add_action('admin_menu', 'abj404_degraded_admin_menu');
593 add_action('admin_notices', 'abj404_degraded_admin_notice');
594 }
595
596 // --- Degraded-mode functions (always defined, no plugin class dependencies) ---
597
598 if (!function_exists('abj404_degraded_admin_menu')) {
599 /** @return void */
600 function abj404_degraded_admin_menu() {
601 $options = function_exists('get_option') ? get_option('abj404_settings') : false;
602 $options = is_array($options) ? $options : array();
603
604 $menuName = '404 Solution';
605 $badge = " <span class='update-plugins count-1'><span class='plugin-count'>!</span></span>";
606
607 if (isset($options['menuLocation']) && $options['menuLocation'] === 'settingsLevel') {
608 add_menu_page('404 Solution', $menuName . $badge, 'manage_options', 'abj404_solution', 'abj404_degraded_admin_page');
609 } else {
610 $ppSlug = defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution';
611 add_submenu_page('options-general.php', '404 Solution', $menuName . $badge, 'manage_options', $ppSlug, 'abj404_degraded_admin_page');
612 }
613 }
614 }
615
616 if (!function_exists('abj404_degraded_admin_notice')) {
617 /** @return void */
618 function abj404_degraded_admin_notice() {
619 if (!current_user_can('manage_options')) {
620 return;
621 }
622 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ';
623 echo 'Plugin files are missing or corrupt. ';
624 $ppSlug = defined('ABJ404_PP') ? ABJ404_PP : 'abj404_solution';
625 echo '<a href="' . esc_url(admin_url('options-general.php?page=' . $ppSlug)) . '">View details</a>';
626 echo '</p></div>';
627 }
628 }
629
630 if (!function_exists('abj404_degraded_admin_page')) {
631 /** @return void */
632 function abj404_degraded_admin_page() {
633 if (!current_user_can('manage_options')) {
634 echo '<div class="wrap">';
635 echo '<h1>404 Solution</h1>';
636 echo '<div class="notice notice-error"><p>';
637 echo '<strong>Permission denied.</strong> ';
638 echo 'Your user account does not have permission to access this page.';
639 echo '</p><p>';
640 echo 'Please verify that your WordPress role has the <code>manage_options</code> capability.';
641 echo '</p></div></div>';
642 return;
643 }
644
645 $missingFiles = isset($GLOBALS['abj404_missing_files']) ? $GLOBALS['abj404_missing_files'] : array();
646 $bootError = isset($GLOBALS['abj404_boot_error']) ? $GLOBALS['abj404_boot_error'] : '';
647 $pluginDir = defined('ABJ404_PATH') ? ABJ404_PATH : dirname(__FILE__) . '/';
648
649 // Check for a stored fatal from a previous request.
650 $fatalInfo = function_exists('get_transient') ? get_transient('abj404_boot_fatal') : false;
651
652 echo '<div class="wrap">';
653 echo '<h1>404 Solution &mdash; Plugin Files Missing</h1>';
654
655 echo '<div class="notice notice-error inline"><p>';
656 echo '<strong>The 404 Solution plugin cannot start</strong> because one or more required files are missing or corrupt. ';
657 echo 'This usually happens after a failed plugin update or incomplete file upload.';
658 echo '</p></div>';
659
660 if (!empty($missingFiles)) {
661 echo '<div class="card" style="max-width:800px;">';
662 echo '<h2>Missing Files</h2>';
663 echo '<ul style="list-style:disc;padding-left:20px;">';
664 foreach ($missingFiles as $file) {
665 // Show relative path for readability.
666 $relative = str_replace($pluginDir, '', $file);
667 echo '<li><code>' . esc_html($relative) . '</code></li>';
668 }
669 echo '</ul>';
670 echo '</div>';
671 }
672
673 if ($bootError !== '') {
674 echo '<div class="card" style="max-width:800px;">';
675 echo '<h2>Error Details</h2>';
676 echo '<pre style="white-space:pre-wrap;word-break:break-all;">' . esc_html($bootError) . '</pre>';
677 echo '</div>';
678 }
679
680 if (is_array($fatalInfo) && !empty($fatalInfo['message'])) {
681 echo '<div class="card" style="max-width:800px;">';
682 echo '<h2>Fatal Error (previous request)</h2>';
683 $fatalFile = isset($fatalInfo['file']) ? str_replace($pluginDir, '', $fatalInfo['file']) : 'unknown';
684 $fatalLine = isset($fatalInfo['line']) ? $fatalInfo['line'] : '?';
685 echo '<pre style="white-space:pre-wrap;word-break:break-all;">' . esc_html($fatalInfo['message']) . "\n" . esc_html($fatalFile) . ':' . esc_html((string)$fatalLine) . '</pre>';
686 echo '</div>';
687 }
688
689 echo '<div class="card" style="max-width:800px;">';
690 echo '<h2>How to Fix</h2>';
691 echo '<ol>';
692 echo '<li>Go to <strong>Plugins &rarr; Installed Plugins</strong>, deactivate <strong>404 Solution</strong>, then delete it.</li>';
693 echo '<li>Reinstall from the WordPress plugin directory: ';
694 $installUrl = admin_url('plugin-install.php?s=404+solution&tab=search');
695 echo '<a href="' . esc_url($installUrl) . '" class="button button-primary">Search &ldquo;404 Solution&rdquo;</a>';
696 echo '</li>';
697 echo '<li>Activate the fresh copy. Your redirects and settings are stored in the database and will not be lost.</li>';
698 echo '</ol>';
699 echo '</div>';
700
701 echo '</div>'; // .wrap
702 }
703 }
704
705 if (!function_exists('abj404_admin_page_callback')) {
706 /**
707 * Show one-time admin fatal diagnostics captured during shutdown.
708 *
709 * @return void
710 */
711 function abj404_render_last_admin_fatal_notice() {
712 if (!function_exists('current_user_can') || !current_user_can('manage_options')) {
713 return;
714 }
715
716 $fatalInfo = function_exists('get_transient') ? get_transient('abj404_admin_fatal') : false;
717 if ($fatalInfo === false && function_exists('get_option')) {
718 $fatalInfo = get_option('abj404_admin_fatal_fallback', false);
719 }
720 if (!is_array($fatalInfo) || empty($fatalInfo['message'])) {
721 return;
722 }
723
724 if (function_exists('delete_transient')) {
725 delete_transient('abj404_admin_fatal');
726 }
727 if (function_exists('delete_option')) {
728 delete_option('abj404_admin_fatal_fallback');
729 }
730
731 $pluginDir = defined('ABJ404_PATH') ? ABJ404_PATH : __DIR__ . '/';
732 $fatalFile = isset($fatalInfo['file']) ? str_replace($pluginDir, '', (string)$fatalInfo['file']) : '(unknown file)';
733 $fatalLine = isset($fatalInfo['line']) ? (int)$fatalInfo['line'] : 0;
734
735 echo '<div class="wrap">';
736 echo '<div class="notice notice-error">';
737 echo '<p><strong>404 Solution:</strong> A fatal error occurred while rendering the previous admin request.</p>';
738 echo '<details><summary>Show error details</summary>';
739 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">' .
740 esc_html((string)$fatalInfo['message'] . "\n" . $fatalFile . ':' . (string)$fatalLine) .
741 '</pre>';
742 echo '</details>';
743 echo '</div>';
744 echo '</div>';
745 }
746
747 /**
748 * Safe wrapper for the admin page callback. Falls back to the degraded
749 * page if the View class was not loaded during boot.
750 *
751 * @return void
752 */
753 function abj404_admin_page_callback() {
754 abj404_render_last_admin_fatal_notice();
755
756 // The false parameter avoids triggering the autoloader — if View was not
757 // loaded during boot, we don't want to attempt loading it again here.
758 if (class_exists('ABJ_404_Solution_View', false)) {
759 ob_start();
760 $renderError = null;
761 try {
762 ABJ_404_Solution_View::handleMainAdminPageActionAndDisplay();
763 } catch (\Throwable $e) {
764 $renderError = $e;
765 error_log('404 Solution: admin page rendering failed: ' .
766 $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
767 }
768 $output = ob_get_clean();
769
770 if ($renderError !== null) {
771 echo '<div class="wrap">';
772 echo '<div class="notice notice-error">';
773 echo '<p><strong>404 Solution:</strong> An error occurred while rendering this page.</p>';
774 echo '<details><summary>Show error details</summary>';
775 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>';
776 echo '</details>';
777 echo '</div>';
778 echo '</div>';
779 } elseif ($output === '' || $output === false) {
780 // The View class was loaded and didn't throw, but produced zero output.
781 // Show a diagnostic instead of a blank page.
782 echo '<div class="wrap">';
783 echo '<h1>404 Solution</h1>';
784 echo '<div class="notice notice-error"><p>';
785 echo '<strong>This page produced no output.</strong> ';
786 echo 'This can happen when a required dependency failed to initialize or a template file is missing.';
787 echo '</p><p>';
788 echo 'Try deactivating and reactivating the plugin. If the problem persists, ';
789 echo 'delete the plugin and reinstall it from the WordPress plugin directory.';
790 echo '</p></div></div>';
791 } else {
792 echo $output;
793 }
794 } else {
795 abj404_degraded_admin_page();
796 }
797 }
798 }
799
800 // ----
801 // get the plugin priority to use before adding the template_redirect action.
802 $__abj404_options = abj404_get_settings_options();
803 $__abj404_redirect_priority_raw = isset($__abj404_options['template_redirect_priority']) && is_scalar($__abj404_options['template_redirect_priority']) ? $__abj404_options['template_redirect_priority'] : 9;
804 $__abj404_template_redirect_priority = absint($__abj404_redirect_priority_raw);
805 $__abj404_redirect_all = isset($__abj404_options['redirect_all_requests']) && is_scalar($__abj404_options['redirect_all_requests']) ? (string)$__abj404_options['redirect_all_requests'] : '';
806 $__abj404_update_suggest = isset($__abj404_options['update_suggest_url']) && is_scalar($__abj404_options['update_suggest_url']) ? (string)$__abj404_options['update_suggest_url'] : '';
807 $GLOBALS['abj404_frontend_runtime_flags'] = array(
808 'redirect_all_requests' => ($__abj404_redirect_all === '1'),
809 'update_suggest_url' => ($__abj404_update_suggest === '1'),
810 );
811 $__abj404_lang_override = isset($__abj404_options['plugin_language_override']) && is_string($__abj404_options['plugin_language_override']) ? $__abj404_options['plugin_language_override'] : '';
812 $GLOBALS['abj404_plugin_language_override'] = $__abj404_lang_override;
813
814 add_action('template_redirect', 'abj404_404listener', $__abj404_template_redirect_priority);
815
816 unset($__abj404_options);
817 unset($__abj404_template_redirect_priority);
818 abj404_benchmark_mark_bootstrap_done();
819 // ---
820
821 // 404
822 if (!function_exists('abj404_404listener')) {
823 /** @return void */
824 function abj404_404listener() {
825 if (!$GLOBALS['abj404_boot_ok']) {
826 return;
827 }
828 $is404 = is_404();
829 if (!$is404) {
830 // Performance: do NOT load the whole plugin on every frontend request unless we must.
831 if (!empty($GLOBALS['abj404_frontend_runtime_flags']['redirect_all_requests'])) {
832 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
833 $connector = ABJ_404_Solution_WordPress_Connector::getInstance();
834 $connector->processRedirectAllRequests();
835 return;
836 }
837
838 $updateSuggestEnabled = !empty($GLOBALS['abj404_frontend_runtime_flags']['update_suggest_url']);
839 $cookieName404 = ABJ404_PP . '_STATUS_404';
840 $has404StatusCookie = (isset($_COOKIE[$cookieName404]) && $_COOKIE[$cookieName404] == 'true');
841
842 // Fast path: if none of the non-404 features are active, bail immediately.
843 if (!$updateSuggestEnabled && !$has404StatusCookie) {
844 return;
845 }
846
847 /** If we're currently redirecting to a custom 404 page and we are about to show page
848 * suggestions then update the URL displayed to the user. */
849 $cookieName = ABJ404_PP . '_REQUEST_URI_UPDATE_URL';
850 $queryParamName = ABJ404_PP . '_ref';
851
852 $hasUpdateCookie = !empty($_COOKIE[$cookieName]);
853 $hasUpdateParam = !empty($_GET[$queryParamName]);
854
855 // Fast path: nothing pending from prior plugin-driven redirects.
856 if (!$hasUpdateCookie && !$hasUpdateParam && !$has404StatusCookie) {
857 return;
858 }
859
860 if ($has404StatusCookie) {
861 // clear the cookie
862 setcookie($cookieName404, 'false', time() - 5, "/");
863 // we're going to a custom 404 page so set the status to 404.
864 status_header(404);
865 }
866
867 if (!$updateSuggestEnabled) {
868 return;
869 }
870
871 // Check cookie first, then query param fallback (for 301 redirects where cookies don't survive)
872 $originalURL = null;
873 if ($hasUpdateCookie) {
874 $originalURL = $_COOKIE[$cookieName];
875 } elseif ($hasUpdateParam) {
876 $originalURL = urldecode($_GET[$queryParamName]);
877 }
878
879 if ($originalURL !== null) {
880 // clear the cookie - sanitize before writing to $_REQUEST
881 $sanitizedOriginal = sanitize_text_field($originalURL);
882 $_REQUEST[ABJ404_PP . '_REQUEST_URI'] = $sanitizedOriginal;
883 $_REQUEST[ABJ404_PP . '_REQUEST_URI_UPDATE_URL'] = $sanitizedOriginal;
884 setcookie($cookieName, '', time() - 5, "/");
885
886 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
887 add_action('wp_head', 'ABJ_404_Solution_ShortCode::updateURLbarIfNecessary');
888 }
889 return;
890 }
891
892 // ignore admin screens and login requests on 404 processing path.
893 // $_SERVER['SCRIPT_NAME'] is not guaranteed (CLI, some test runners, some proxies).
894 // Use a direct script-name check to avoid invoking wp_login_url() filters.
895 $scriptName = $_SERVER['SCRIPT_NAME'] ?? '';
896 $requestUri = $_SERVER['REQUEST_URI'] ?? '';
897 $isLoginScreen = (
898 ($scriptName !== '' && stripos($scriptName, 'wp-login.php') !== false) ||
899 ($requestUri !== '' && stripos($requestUri, 'wp-login.php') !== false)
900 );
901 if (is_admin() || $isLoginScreen) {
902 return;
903 }
904
905 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
906 $connector = ABJ_404_Solution_WordPress_Connector::getInstance();
907 $connector->process404();
908 }
909 }
910
911 if (!function_exists('abj404_is_redirect_all_requests_enabled')) {
912 /**
913 * Small helper for testability and to keep option-parsing logic consistent.
914 *
915 * @param mixed $options Value returned by get_option('abj404_settings')
916 * @return bool
917 */
918 function abj404_is_redirect_all_requests_enabled($options) {
919 return is_array($options) &&
920 array_key_exists('redirect_all_requests', $options) &&
921 (string)$options['redirect_all_requests'] === '1';
922 }
923 }
924
925 if (!function_exists('abj404_dailyMaintenanceCronJobListener')) {
926 /** @return void */
927 function abj404_dailyMaintenanceCronJobListener() {
928 try {
929 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
930 $abj404dao = ABJ_404_Solution_DataAccess::getInstance();
931 $abj404dao->deleteOldRedirectsCron();
932
933 $dbUpgrades = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
934 $dbUpgrades->runDatabaseMaintenanceTasks();
935 } catch (\Throwable $e) {
936 error_log('404 Solution cron (maintenance): ' . $e->getMessage());
937 }
938 }
939 }
940
941 if (!function_exists('abj404_updateLogsHitsTableListener')) {
942 /** @return void */
943 function abj404_updateLogsHitsTableListener() {
944 try {
945 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
946 $abj404dao = ABJ_404_Solution_DataAccess::getInstance();
947 $abj404dao->createRedirectsForViewHitsTable();
948 } catch (\Throwable $e) {
949 error_log('404 Solution cron (logs/hits): ' . $e->getMessage());
950 }
951 }
952 }
953 if (!function_exists('abj404_logsv2CanonicalUrlBackfillListener')) {
954 /** @return void */
955 function abj404_logsv2CanonicalUrlBackfillListener() {
956 try {
957 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
958 $dbUpgrades = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
959 $dbUpgrades->backfillLogsv2CanonicalUrl();
960 } catch (\Throwable $e) {
961 error_log('404 Solution cron (log canonical URL backfill): ' . $e->getMessage());
962 }
963 }
964 }
965 if (!function_exists('abj404_updatePermalinkCacheListener')) {
966 /**
967 * @param int $maxExecutionTime
968 * @param int $executionCount
969 * @return void
970 */
971 function abj404_updatePermalinkCacheListener($maxExecutionTime, $executionCount = 1) {
972 try {
973 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
974 $permalinkCache = ABJ_404_Solution_PermalinkCache::getInstance();
975 $permalinkCache->updatePermalinkCache($maxExecutionTime, $executionCount);
976 } catch (\Throwable $e) {
977 error_log('404 Solution cron (permalink cache): ' . $e->getMessage());
978 }
979 }
980 }
981 if (!function_exists('abj404_rebuildNGramCacheListener')) {
982 /**
983 * @param int $offset
984 * @return void
985 */
986 function abj404_rebuildNGramCacheListener($offset = 0) {
987 try {
988 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
989 $dbUpgrades = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
990 $dbUpgrades->rebuildNGramCacheAsync($offset);
991 } catch (\Throwable $e) {
992 error_log('404 Solution cron (ngram cache): ' . $e->getMessage());
993 }
994 }
995 }
996 if (!function_exists('abj404_networkActivationListener')) {
997 /** @return void */
998 function abj404_networkActivationListener() {
999 try {
1000 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1001 ABJ_404_Solution_PluginLogic::networkActivationCronHandler();
1002 } catch (\Throwable $e) {
1003 error_log('404 Solution cron (network activation): ' . $e->getMessage());
1004 }
1005 }
1006 }
1007 if (!function_exists('abj404_networkActivationBackgroundListener')) {
1008 /** @return void */
1009 function abj404_networkActivationBackgroundListener() {
1010 try {
1011 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1012 $upgradesEtc = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1013 $upgradesEtc->processMultisiteActivationBatch();
1014 } catch (\Throwable $e) {
1015 error_log('404 Solution cron (multisite activation): ' . $e->getMessage());
1016 }
1017 }
1018 }
1019 if (!function_exists('abj404_networkUpgradeBackgroundListener')) {
1020 /** @return void */
1021 function abj404_networkUpgradeBackgroundListener() {
1022 try {
1023 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1024 $upgradesEtc = ABJ_404_Solution_DatabaseUpgradesEtc::getInstance();
1025 $upgradesEtc->processMultisiteUpgradeBatch();
1026 } catch (\Throwable $e) {
1027 error_log('404 Solution cron (multisite upgrade): ' . $e->getMessage());
1028 }
1029 }
1030 }
1031 add_action('abj404_cleanupCronAction', 'abj404_dailyMaintenanceCronJobListener');
1032 add_action('abj404_updateLogsHitsTableAction', 'abj404_updateLogsHitsTableListener');
1033 add_action('abj404_logsv2_canonical_backfill', 'abj404_logsv2CanonicalUrlBackfillListener');
1034 add_action('abj404_updatePermalinkCacheAction', 'abj404_updatePermalinkCacheListener', 10, 2);
1035 add_action('abj404_send_digest', 'abj404_sendDigestCronListener');
1036 if (!function_exists('abj404_sendDigestCronListener')) {
1037 /** @return void */
1038 function abj404_sendDigestCronListener() {
1039 try {
1040 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1041 $dao = ABJ_404_Solution_DataAccess::getInstance();
1042 $logger = ABJ_404_Solution_Logging::getInstance();
1043 $emailDigest = new ABJ_404_Solution_EmailDigest($dao, $logger);
1044 $emailDigest->onCronSendDigest();
1045 } catch (\Throwable $e) {
1046 error_log('404 Solution cron (email digest): ' . $e->getMessage());
1047 }
1048 }
1049 }
1050 add_action('abj404_rebuild_ngram_cache_hook', 'abj404_rebuildNGramCacheListener', 10, 1);
1051 add_action('abj404_network_activation_hook', 'abj404_networkActivationListener');
1052 add_action('abj404_network_activation_background', 'abj404_networkActivationBackgroundListener');
1053 add_action('abj404_network_upgrade_background', 'abj404_networkUpgradeBackgroundListener');
1054 add_action('abj404_gsc_fetch_cron', 'abj404_gscFetchCronListener');
1055 add_action('abj404_gsc_background_refresh', 'abj404_gscBackgroundRefreshListener');
1056
1057 if (!function_exists('abj404_gscFetchCronListener')) {
1058 /** Nightly cron: fetch GSC data and cache it. @return void */
1059 function abj404_gscFetchCronListener(): void {
1060 try {
1061 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1062 $gscLogger = ABJ_404_Solution_Logging::getInstance();
1063 $gsc = new ABJ_404_Solution_GoogleSearchConsole($gscLogger);
1064 $gsc->fetchAndCacheGscData();
1065 } catch (\Throwable $e) {
1066 error_log('404 Solution cron (GSC fetch): ' . $e->getMessage());
1067 }
1068 }
1069 }
1070
1071 if (!function_exists('abj404_gscBackgroundRefreshListener')) {
1072 /** On-demand background refresh triggered when an admin views the Options tab with stale data. @return void */
1073 function abj404_gscBackgroundRefreshListener(): void {
1074 try {
1075 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1076 $gscLogger = ABJ_404_Solution_Logging::getInstance();
1077 $gsc = new ABJ_404_Solution_GoogleSearchConsole($gscLogger);
1078 $gsc->fetchAndCacheGscData();
1079 } catch (\Throwable $e) {
1080 error_log('404 Solution cron (GSC background refresh): ' . $e->getMessage());
1081 }
1082 }
1083 }
1084
1085 /**
1086 * Override the locale for this plugin if user has configured a language override.
1087 * This allows users to use a different language for the 404 Solution plugin
1088 * than their WordPress site language or user language preference.
1089 *
1090 * @param string $locale The current locale.
1091 * @param string $domain The text domain.
1092 * @return string The locale to use for translation loading.
1093 */
1094 if (!function_exists('abj404_override_plugin_locale')) {
1095 /**
1096 * @param string $locale
1097 * @param string $domain
1098 * @return string
1099 */
1100 function abj404_override_plugin_locale($locale, $domain) {
1101 // Only override for our plugin's text domain.
1102 // Use the value cached in $GLOBALS at plugin boot to avoid a redundant get_option() call.
1103 if ($domain === '404-solution') {
1104 $override = isset($GLOBALS['abj404_plugin_language_override']) && is_string($GLOBALS['abj404_plugin_language_override']) ? $GLOBALS['abj404_plugin_language_override'] : '';
1105 if ($override !== '') {
1106 return $override;
1107 }
1108 }
1109 return $locale;
1110 }
1111 }
1112 add_filter('plugin_locale', 'abj404_override_plugin_locale', 999, 2);
1113
1114 if (!function_exists('abj404_show_runtime_integrity_notice')) {
1115 /** @return void */
1116 function abj404_show_runtime_integrity_notice() {
1117 if (!is_admin() || !current_user_can('manage_options')) {
1118 return;
1119 }
1120 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
1121 if ($page !== ABJ404_PP) {
1122 return;
1123 }
1124 $missing = get_transient('abj404_runtime_missing_files');
1125 if (!is_array($missing) || count($missing) === 0) {
1126 return;
1127 }
1128 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ';
1129 echo esc_html(__('Some required plugin files are missing. Please reinstall the plugin package.', '404-solution'));
1130 echo '</p><p><code>' . esc_html(implode(', ', array_map('basename', $missing))) . '</code></p></div>';
1131 }
1132 }
1133 add_action('admin_notices', 'abj404_show_runtime_integrity_notice');
1134
1135 if (!function_exists('abj404_show_plugin_db_notice')) {
1136 /** @return void */
1137 function abj404_show_plugin_db_notice() {
1138 if (!is_admin() || !current_user_can('manage_options')) {
1139 return;
1140 }
1141 $page = isset($_GET['page']) ? sanitize_text_field($_GET['page']) : '';
1142 if ($page !== ABJ404_PP) {
1143 return;
1144 }
1145 $notice = get_transient('abj404_plugin_db_notice');
1146 if (!is_array($notice) || empty($notice['message'])) {
1147 return;
1148 }
1149 $type = isset($notice['type']) ? $notice['type'] : '';
1150 // Collation issues are developer-level; don't show them to the user.
1151 if ($type === 'collation') {
1152 return;
1153 }
1154 $guidance = '';
1155 if ($type === 'disk_full') {
1156 $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');
1157 } elseif ($type === 'read_only') {
1158 $guidance = __('Your database is currently in read-only mode. Contact your hosting provider.', '404-solution');
1159 } elseif ($type === 'query_quota') {
1160 $guidance = __('Your database query quota was exceeded. This usually resets automatically.', '404-solution');
1161 } elseif ($type === 'corrupted_temp_table') {
1162 $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');
1163 } elseif ($type === 'log_table_full') {
1164 $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');
1165 } elseif ($type === 'stale_permalink_cache') {
1166 $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');
1167 } elseif ($type === 'lock_timeout') {
1168 $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');
1169 }
1170 echo '<div class="notice notice-error"><p><strong>404 Solution:</strong> ' . esc_html($notice['message']) . '</p>';
1171 if ($guidance !== '') {
1172 echo '<p>' . esc_html($guidance) . '</p>';
1173 }
1174 if (!empty($notice['error_string'])) {
1175 echo '<details><summary>' . esc_html(__('Show database error details', '404-solution')) . '</summary>';
1176 echo '<pre style="white-space:pre-wrap;word-break:break-all;max-width:100%;margin:6px 0;">' . esc_html($notice['error_string']) . '</pre></details>';
1177 }
1178 echo '</div>';
1179 }
1180 }
1181 add_action('admin_notices', 'abj404_show_plugin_db_notice');
1182
1183 if (!function_exists('abj404_get_simulated_db_latency_ms')) {
1184 /** @return bool */
1185 function abj404_is_local_debug_host() {
1186 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '');
1187 $serverName = strtolower(trim((string)$serverName));
1188 if ($serverName === '') {
1189 return false;
1190 }
1191
1192 $normalizedHost = $serverName;
1193 if (strpos($normalizedHost, '[') === 0) {
1194 $endBracket = strpos($normalizedHost, ']');
1195 if ($endBracket !== false) {
1196 $normalizedHost = substr($normalizedHost, 1, $endBracket - 1);
1197 }
1198 } else {
1199 $colonCount = substr_count($normalizedHost, ':');
1200 if ($colonCount === 1 && preg_match('/:\d+$/', $normalizedHost)) {
1201 $normalizedHost = preg_replace('/:\d+$/', '', $normalizedHost);
1202 }
1203 }
1204
1205 $normalizedHost = rtrim((string)$normalizedHost, '.');
1206 return in_array($normalizedHost, array('127.0.0.1', '::1', 'localhost'), true);
1207 }
1208
1209 /** @return int */
1210 function abj404_get_simulated_db_latency_ms() {
1211 if (!abj404_is_local_debug_host()) {
1212 return 0;
1213 }
1214 if (defined('ABJ404_SIMULATED_DB_LATENCY_MS')) {
1215 return max(0, min(5000, absint(ABJ404_SIMULATED_DB_LATENCY_MS)));
1216 }
1217 $value = get_option('abj404_simulated_db_latency_ms', 0);
1218 return max(0, min(5000, absint(is_scalar($value) ? $value : 0)));
1219 }
1220 }
1221
1222 if (!function_exists('abj404_show_diagnostic_latency_notice')) {
1223 /** @return void */
1224 function abj404_show_diagnostic_latency_notice() {
1225 // Intentionally no-op. Simulated latency status is shown in the plugin's
1226 // Tools > Diagnostics card to avoid intrusive floating/global notices.
1227 return;
1228 }
1229 }
1230
1231 if (!function_exists('abj404_load_textdomain_if_needed')) {
1232 /**
1233 * Load plugin translations once, lazily.
1234 *
1235 * @return void
1236 */
1237 function abj404_load_textdomain_if_needed() {
1238 static $loaded = false;
1239 if ($loaded) {
1240 return;
1241 }
1242
1243 $override_locale = '';
1244 if (!empty($GLOBALS['abj404_plugin_language_override'])) {
1245 $override_locale = (string)$GLOBALS['abj404_plugin_language_override'];
1246 } else {
1247 $options = abj404_get_settings_options();
1248 $override_locale = (is_array($options) && !empty($options['plugin_language_override']))
1249 ? $options['plugin_language_override'] : '';
1250 }
1251
1252 if (!empty($override_locale)) {
1253 $mo_file = ABJ404_PATH . 'languages/404-solution-' . $override_locale . '.mo';
1254 if (file_exists($mo_file)) {
1255 load_textdomain('404-solution', $mo_file);
1256 }
1257 } else {
1258 $lang_dir = dirname(plugin_basename(ABJ404_FILE)) . '/languages';
1259 load_plugin_textdomain('404-solution', false, $lang_dir);
1260 }
1261
1262 $loaded = true;
1263 }
1264 }
1265
1266 if (!function_exists('abj404_maybe_refresh_runtime_integrity_cache')) {
1267 /**
1268 * Refresh runtime integrity cache at most once per TTL window.
1269 *
1270 * @param int $ttlSeconds
1271 * @return void
1272 */
1273 function abj404_maybe_refresh_runtime_integrity_cache($ttlSeconds = 43200) {
1274 if (!is_admin()) {
1275 return;
1276 }
1277
1278 $checkedRecently = get_transient('abj404_runtime_integrity_checked');
1279 if ($checkedRecently) {
1280 return;
1281 }
1282
1283 $missingRuntimeFiles = abj404_verify_runtime_integrity();
1284 if (count($missingRuntimeFiles) > 0) {
1285 set_transient('abj404_runtime_missing_files', $missingRuntimeFiles, $ttlSeconds);
1286 } else {
1287 delete_transient('abj404_runtime_missing_files');
1288 }
1289
1290 set_transient('abj404_runtime_integrity_checked', 1, $ttlSeconds);
1291 }
1292 }
1293
1294 /** This only runs after WordPress is done enqueuing scripts. */
1295 if (!function_exists('abj404_loadSomethingWhenWordPressIsReady')) {
1296 /** @return void */
1297 function abj404_loadSomethingWhenWordPressIsReady() {
1298 // If boot failed (missing files), skip all init that depends on plugin classes.
1299 if (!$GLOBALS['abj404_boot_ok']) {
1300 return;
1301 }
1302
1303 $isAdminRequest = is_admin();
1304 if ($isAdminRequest) {
1305 abj404_load_textdomain_if_needed();
1306 }
1307
1308 // make debugging easier on localhost etc
1309 if ($isAdminRequest) {
1310 $serverName = array_key_exists('SERVER_NAME', $_SERVER) ? $_SERVER['SERVER_NAME'] : (array_key_exists('HTTP_HOST', $_SERVER) ? $_SERVER['HTTP_HOST'] : '(not found)');
1311 $serverNameIsInTheWhiteList = in_array($serverName, $GLOBALS['abj404_whitelist']);
1312
1313 // Keep localhost debug helper on admin screens only; frontend requests stay lean.
1314 if ($serverNameIsInTheWhiteList && function_exists('wp_get_current_user')) {
1315 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1316 $abj404logic = ABJ_404_Solution_PluginLogic::getInstance();
1317 if ($abj404logic->userIsPluginAdmin()) {
1318 $GLOBALS['abj404_display_errors'] = true;
1319 }
1320 }
1321 }
1322
1323 $action = null;
1324 if ($isAdminRequest) {
1325 $action = isset($_GET['action']) ? sanitize_text_field($_GET['action']) : (isset($_POST['action']) ? sanitize_text_field($_POST['action']) : null);
1326 }
1327 if ($isAdminRequest && abj404_is_local_debug_host() && current_user_can('manage_options') && isset($_GET['abj404_set_sim_db_ms'])) {
1328 $nonceOk = isset($_GET['_wpnonce']) ? wp_verify_nonce($_GET['_wpnonce'], 'abj404_set_sim_db_ms') : false;
1329 if ($nonceOk) {
1330 $newMs = max(0, min(5000, absint($_GET['abj404_set_sim_db_ms'])));
1331 update_option('abj404_simulated_db_latency_ms', $newMs, false);
1332 }
1333 }
1334
1335 $ttl = defined('HOUR_IN_SECONDS') ? (12 * HOUR_IN_SECONDS) : 43200;
1336 abj404_maybe_refresh_runtime_integrity_cache($ttl);
1337
1338 if ($isAdminRequest && $action === 'exportRedirects') {
1339 require_once(plugin_dir_path( __FILE__ ) . "includes/Loader.php");
1340 $abj404logic = ABJ_404_Solution_PluginLogic::getInstance();
1341 $abj404logic->handleActionExport();
1342 }
1343 }
1344 }
1345 add_action('admin_init', 'abj404_loadSomethingWhenWordPressIsReady');
1346