PluginProbe
404 Solution / 4.1.6
404 Solution v4.1.6
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.6, at 404-solution.php

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