PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / includes / core / WordPress_Connector.php

WordPress_Connector.php in 404 Solution trunk, at includes/core/WordPress_Connector.php

461 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 /* Functions in this class should only be for plugging into WordPress listeners (filters, actions, etc). */
9
10 class ABJ_404_Solution_WordPress_Connector {
11
12 /** @var self|null */
13 private static $instance = null;
14 /**
15 * Test seam: install or clear the cached singleton instance without
16 * private-field reflection. Pass null to reset between tests; pass a
17 * configured instance (or double) to install it. Mirrors the setInstance()
18 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
19 *
20 * @param self|null $instance
21 * @return void
22 */
23 public static function setInstance($instance) {
24 self::$instance = $instance;
25 }
26
27
28 /** @var ABJ_404_Solution_PluginLogic */
29 private $logic;
30
31 /** @var ABJ_404_Solution_RedirectsRepository */ private $redirectsRepository;
32
33 /** @var mixed */ private $logsRepository;
34
35 /** @var mixed */ private $statsRepository;
36
37 /** @var ABJ_404_Solution_Logging */
38 private $logger;
39
40 /** @var ABJ_404_Solution_Functions */
41 private $f;
42
43 /** @var ABJ_404_Solution_SpellChecker */
44 private $spellChecker;
45
46 /** @var ABJ_404_Solution_FrontendRequestPipeline|null */
47 private $frontendPipeline = null;
48
49 /**
50 * Constructor with dependency injection.
51 *
52 * @param ABJ_404_Solution_WordPressConnectorDependencies|null $deps
53 */
54 public function __construct(?ABJ_404_Solution_WordPressConnectorDependencies $deps = null) {
55 $deps = $deps ?? new ABJ_404_Solution_WordPressConnectorDependencies();
56 $redirectsRepository = $deps->redirectsRepository;
57 $this->logic = $deps->pluginLogic !== null ? $deps->pluginLogic : abj_service('plugin_logic');
58 $this->redirectsRepository = $redirectsRepository !== null ? $redirectsRepository : abj_service('redirects_repository');
59 $this->logger = $deps->logging !== null ? $deps->logging : abj_service('logging');
60 $this->f = $deps->functions !== null ? $deps->functions : abj_service('functions');
61 $this->spellChecker = $deps->spellChecker !== null ? $deps->spellChecker : abj_service('spell_checker');
62 $this->logsRepository = $deps->logsRepository !== null ? $deps->logsRepository :
63 (is_object($redirectsRepository) && method_exists($redirectsRepository, 'logRedirectHit') ? $redirectsRepository : abj_service('logs_repository'));
64 $this->statsRepository = $deps->statsRepository !== null ? $deps->statsRepository :
65 (is_object($redirectsRepository) && method_exists($redirectsRepository, 'getCapturedCountForNotification')
66 ? $redirectsRepository
67 : ABJ_404_Solution_StatsRepositoryResolver::resolve(__CLASS__));
68 }
69
70 /** @return ABJ_404_Solution_FrontendRequestPipeline */
71 private function getFrontendPipeline() {
72 if ($this->frontendPipeline !== null) {
73 return $this->frontendPipeline;
74 }
75
76 if (!class_exists('ABJ_404_Solution_FrontendRequestPipeline')) {
77 require_once __DIR__ . '/../frontend/FrontendRequestPipeline.php';
78 }
79
80 $matchingEngines = [];
81 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
82 $engines = ABJ_404_Solution_ServiceContainer::safeGet('matching_engines');
83 if (is_array($engines)) {
84 $matchingEngines = $engines;
85 }
86 }
87
88 $dependencies = new ABJ_404_Solution_FrontendPipelineDependencies(
89 $this->logic,
90 $this->redirectsRepository,
91 $this->logger,
92 $this->f,
93 $this->spellChecker,
94 $matchingEngines,
95 $this->logsRepository,
96 abj_service('not_found_response'),
97 abj_service('request_ignore_normalizer'),
98 abj_service('previous_request_cookie_tracker')
99 );
100 $this->frontendPipeline = new ABJ_404_Solution_FrontendRequestPipeline($dependencies);
101 return $this->frontendPipeline;
102 }
103
104 public function getCapturedCountForNotification(): int {
105 if (!is_object($this->statsRepository) || !method_exists($this->statsRepository, 'getCapturedCountForNotification')) { return 0; } try { return (int)call_user_func(array($this->statsRepository, 'getCapturedCountForNotification')); } catch (Throwable $e) {
106 if (is_object($this->logger) && method_exists($this->logger, 'errorMessage')) { $this->logger->errorMessage('Captured-count notification lookup failed: ' . $e->getMessage(), $e instanceof Exception ? $e : null); } else { $this->logWarning('Captured-count notification lookup failed: ' . $e->getMessage()); } return 0; }
107 }
108
109 /** @return ABJ_404_Solution_PluginLogic */
110 public function getPluginLogic() {
111 return $this->logic;
112 }
113
114 /** @return ABJ_404_Solution_Logging */
115 public function getLogger() {
116 return $this->logger;
117 }
118
119 /** @return self */
120 public static function getInstance() {
121 if (self::$instance !== null) {
122 return self::$instance;
123 }
124
125 // If the DI container is initialized, prefer it.
126 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
127 $svc = ABJ_404_Solution_ServiceContainer::safeGet('wordpress_connector');
128 if ($svc instanceof self) {
129 self::$instance = $svc;
130 return self::$instance;
131 }
132 }
133
134 self::$instance = new ABJ_404_Solution_WordPress_Connector();
135
136 return self::$instance;
137 }
138
139 /** Setup.
140 * @return void
141 */
142 static function init() {
143 ABJ_404_Solution_WordPressHookRegistrar::registerAll(self::wordpressHookCallbacks());
144 }
145
146 /**
147 * @return array<string, callable-string>
148 */
149 private static function wordpressHookCallbacks(): array {
150 return array(
151 'settings_link' => __CLASS__ . '::addSettingsLinkToPluginPage',
152 'plugin_row_meta' => __CLASS__ . '::addPluginRowMeta',
153 'review_notice' => 'ABJ_404_Solution_ReviewFeedback::echoDashboardNotification',
154 'review_redirects' => 'ABJ_404_Solution_ReviewFeedback::handleResponseRedirects',
155 'settings_page' => __CLASS__ . '::addMainSettingsPageLink',
156 'admin_assets' => __CLASS__ . '::add_scripts',
157 'plugins_page_assets' => __CLASS__ . '::enqueueSupportRequestAssetsOnPluginsPage',
158 'admin_theme_css' => 'ABJ_404_Solution_AdminThemeManager::outputCriticalThemeCSS',
159 'ajax_view_logs' => 'ABJ_404_Solution_Ajax_ViewLogs::echoViewLogsFor',
160 'ajax_trash_link' => 'ABJ_404_Solution_Ajax_TrashLink::trashAction',
161 'ajax_redirect_to_pages' => 'ABJ_404_Solution_Ajax_RedirectDestinationAutocomplete::echoRedirectToPages',
162 'ajax_update_options' => 'ABJ_404_Solution_Ajax_UpdateOptions::updateOptions',
163 'ajax_load_gsc_section' => 'ABJ_404_Solution_Ajax_LoadGscSection::loadGscSection',
164 'ajax_trend_data' => 'ABJ_404_Solution_Ajax_TrendData::echoTrendData',
165 'ajax_cross_plugin_preview' => 'ABJ_404_Solution_Ajax_CrossPluginImporter::handlePreview',
166 'ajax_gsc_oauth_callback' => 'ABJ_404_Solution_GscOAuthHandler::handleCallback',
167 'ajax_gsc_revoke' => 'ABJ_404_Solution_GscOAuthHandler::handleRevoke',
168 'ajax_compute_suggestions' => 'ABJ_404_Solution_Ajax_SuggestionCompute::computeSuggestions',
169 'ajax_poll_suggestions' => 'ABJ_404_Solution_Ajax_SuggestionPolling::pollSuggestions',
170 );
171 }
172
173 /** Include things necessary for ajax.
174 * @param string $hook
175 * @return void
176 */
177 static function add_scripts($hook) {
178 ABJ_404_Solution_AdminAssetEnqueuer::addScripts($hook, array('ABJ_404_Solution_AdminRuntimeErrorNotice', 'reportAdminRuntimeError'));
179 }
180
181 /**
182 * Enqueue the reusable support-request button assets. Loaded on
183 * every plugin admin page so any screen can drop a
184 * SupportRequestButton::render() mount-point without a per-screen
185 * enqueue checklist that drifts as new mount points are added.
186 *
187 * The inline bootstrap exposes window.ABJ404.ajaxurl plus
188 * window.ABJ404.nonces.{support_request, support_request_preview}
189 * so the JS client and the modal component can both reach the
190 * nonces without wp_localize_script's per-handle binding.
191 *
192 * @return void
193 */
194 /**
195 * Enqueue the support-request button assets specifically for the
196 * wp-admin/plugins.php screen so the row-meta link added by
197 * `addPluginRowMeta()` can open its consent modal in-place. The
198 * plugin's main `add_scripts()` enqueue is gated to the plugin's
199 * settings page and would skip plugins.php otherwise.
200 *
201 * Scope: this hook runs on every admin page but no-ops unless the
202 * current screen is plugins.php, keeping the asset footprint tight.
203 *
204 * @param string $hook the admin page slug WP passes to admin_enqueue_scripts
205 * @return void
206 */
207 static function enqueueSupportRequestAssetsOnPluginsPage($hook) {
208 ABJ_404_Solution_AdminAssetEnqueuer::enqueueSupportRequestAssetsOnPluginsPage(
209 $hook,
210 array('ABJ_404_Solution_AdminRuntimeErrorNotice', 'reportAdminRuntimeError')
211 );
212 }
213
214 /**
215 * @param string $content
216 * @return string
217 */
218 static function remove_admin_footer_text($content) {
219 return '';
220 }
221
222 /** Add the "Settings" link to the WordPress plugins page (next to activate/deactivate and edit).
223 * @param array<int|string, string> $links
224 * @return array<int|string, string>
225 */
226 static function addSettingsLinkToPluginPage($links) {
227 $instance = self::getInstance();
228
229 if (!is_array($links)) {
230 $instance->logger->infoMessage("The settings links variable was not an array. " .
231 "Please verify the validity of other plugins. " . print_r($links, true));
232 $links = array();
233 }
234
235 $isPluginAdmin = abj_service('admin_access_policy')->isPluginAdmin();
236 if (!is_admin() || !$isPluginAdmin) {
237 $instance->logger->logUserCapabilities("addSettingsLinkToPluginPage");
238
239 return $links;
240 }
241
242 $settings_link = self::renderPluginRowLink(
243 esc_url('options-general.php?page=' . ABJ404_PP . '&subpage=abj404_options'),
244 esc_html__('Settings', '404-solution')
245 );
246 array_unshift($links, $settings_link);
247
248 $debugExplanation = __('Debug Log', '404-solution');
249 $debugLogLink = '?page=' . ABJ404_PP . '&subpage=abj404_debugfile';
250 $debugExplanation = self::renderPluginRowLink(
251 esc_url('options-general.php' . $debugLogLink),
252 esc_html($debugExplanation),
253 ' target="_blank"'
254 );
255 array_push($links, $debugExplanation);
256
257 return $links;
258 }
259
260 /**
261 * Adds a "Send debug log to developer" link to the plugin row on
262 * the Plugins page. The link opens the support-request consent
263 * modal in-place on wp-admin/plugins.php (handled by
264 * support-request-button.js, which attaches to elements matching
265 * .abj404-support-request-link). Opening in-place is deliberate:
266 * the Plugins listing is the screen an admin reaches when the
267 * plugin's own Settings page is broken, so the modal must not
268 * depend on Settings rendering correctly.
269 *
270 * The href falls back to the same-page anchor `#abj404-support-request`
271 * so the link is still well-formed if support-request-button.js
272 * fails to load. The modal itself is the only path that transmits
273 * the support-request payload; clicking the link never POSTs.
274 *
275 * @param array<int|string, string> $links
276 * @param string $file
277 * @return array<int|string, string>
278 */
279 static function addPluginRowMeta($links, $file) {
280 if ($file !== ABJ404_NAME) {
281 return $links;
282 }
283 $links[] = self::renderTemplate('supportRequestInlineLink.html', array(
284 '{triggered_from}' => esc_attr('plugins_row_action'),
285 '{context_summary_attr}' => '',
286 '{label}' => esc_html__('Send debug log to developer', '404-solution'),
287 ));
288 return $links;
289 }
290
291 /**
292 * @param string $href
293 * @param string $label
294 * @param string $targetAttr
295 * @return string
296 */
297 private static function renderPluginRowLink(string $href, string $label, string $targetAttr = ''): string {
298 return self::renderTemplate('pluginRowLink.html', array(
299 '{href}' => $href,
300 '{target_attr}' => $targetAttr,
301 '{label}' => $label,
302 ));
303 }
304
305 /**
306 * @param string $templateName
307 * @param array<string, string> $replacements
308 * @return string
309 */
310 private static function renderTemplate(string $templateName, array $replacements): string {
311 $template = ABJ_404_Solution_FileSystemService::readFileContents(
312 dirname(__DIR__) . '/html/' . $templateName,
313 false
314 );
315 return str_replace(array_keys($replacements), array_values($replacements), $template);
316 }
317
318 /** This is called directly by php code inserted into the page by the user.
319 * Code: <?php if (!empty($abj404connector)) {$abj404connector->suggestions(); } ?>
320 * @global type $abj404shortCode
321 */
322 /** @return void */
323 function suggestions() {
324 $abj404shortCode = abj_service('shortcode');
325
326 if (is_404()) {
327 $content = $abj404shortCode->shortcodePageSuggestions(array());
328
329 echo $content;
330 }
331 }
332
333 /** @return void */
334 function processRedirectAllRequests() {
335 $this->getFrontendPipeline()->processRedirectAllRequests();
336 }
337 /**
338 * Process the 404s
339 */
340 /** @return void */
341 function process404() {
342 $this->getFrontendPipeline()->process404();
343 }
344
345 /**
346 * Safely unslash request data when wp_unslash exists and is callable.
347 * Some test environments report wp_unslash as existing but throw when called.
348 *
349 * @param mixed $value
350 * @return mixed
351 */
352 public static function safeWpUnslash($value) {
353 return ABJ_404_Solution_RequestInputNormalizer::safeWpUnslash($value);
354 }
355
356 /**
357 * Normalize request input to a scalar string to avoid warnings when arrays/objects are passed.
358 *
359 * @param mixed $value
360 * @return string
361 */
362 public static function normalizeRequestScalar($value) {
363 return ABJ_404_Solution_RequestInputNormalizer::normalizeScalar($value);
364 }
365
366 /**
367 * Normalize and sanitize feedback issue selections from request data.
368 *
369 * @param mixed $issuesRaw
370 * @return array<int, string>
371 */
372 public static function sanitizeFeedbackIssues($issuesRaw) {
373 return ABJ_404_Solution_RequestInputNormalizer::sanitizeFeedbackIssues($issuesRaw);
374 }
375
376 /** Adds a link under the "Settings" link to the plugin page.
377 * @global string $menu
378 * @global type $abj404dao
379 * @global type $abj404logic
380 * @global type $abj404logging
381 */
382 /** @return void */
383 static function addMainSettingsPageLink() {
384 global $menu;
385
386 // The menu must ALWAYS be registered so the admin page is accessible.
387 // Wrap all pre-registration logic in try/catch — if anything fails
388 // (missing tables, broken service container, etc.), fall through to
389 // register the menu with safe defaults.
390 $pageName = "404 Solution";
391 $menuLocation = '';
392
393 try {
394 $instance = self::getInstance();
395
396 if (!is_admin() || !abj_service('admin_access_policy')->isPluginAdmin()) {
397 $instance->logger->logUserCapabilities("addMainSettingsPageLink");
398 return;
399 }
400
401 // Use skip_db_check=true so menu registration never triggers
402 // updateToNewVersion() — that can hang on slow database upgrades
403 // and block the entire admin page from rendering.
404 $options = abj_service('options_repository')->getOptions(true);
405 $menuLocation = isset($options['menuLocation']) ? $options['menuLocation'] : '';
406
407 // Admin notice badge
408 if (isset($options['admin_notification']) && $options['admin_notification'] != '0') {
409 $captured = $instance->getCapturedCountForNotification();
410 if ($captured >= $options['admin_notification']) {
411 $pageName .= " <span class='update-plugins count-1'><span class='update-count'>" . esc_html((string)$captured) . "</span></span>";
412 if (isset($menu[80][0])) {
413 $pos = $instance->f->strpos($menu[80][0], 'update-plugins');
414 if ($pos === false) {
415 $menu[80][0] = $menu[80][0] . " <span class='update-plugins count-1'><span class='update-count'>1</span></span>";
416 }
417 }
418 }
419 }
420 } catch (\Throwable $e) {
421 // Something failed before menu registration. Continue with defaults
422 // so the admin page is still accessible for debugging. Surface the
423 // failure through the plugin log so it isn't completely invisible.
424 self::logWarningUsingLogger(
425 isset($instance) && $instance instanceof self ? $instance->logger : null,
426 'addMainSettingsPageLink pre-registration failed: ' . $e->getMessage()
427 );
428 }
429
430 if ($menuLocation === 'settingsLevel') {
431 // this adds the settings link at the same level as the "Tools" and "Settings" menu items.
432 $GLOBALS['abj404_settingsPageName'] = add_menu_page(PLUGIN_NAME, PLUGIN_NAME, 'manage_options', 'abj404_solution',
433 'abj404_admin_page_callback');
434
435 } else {
436 // this adds the settings link at Settings->404 Solution.
437 $GLOBALS['abj404_settingsPageName'] = add_submenu_page('options-general.php', PLUGIN_NAME, $pageName, 'manage_options', ABJ404_PP,
438 'abj404_admin_page_callback');
439 }
440 }
441
442 private function logWarning(string $message): void {
443 self::logWarningUsingLogger($this->logger, $message);
444 }
445
446 private static function logWarningUsingLogger(?object $logger, string $message): void {
447 if (is_object($logger) && method_exists($logger, 'warn')) {
448 $logger->warn($message);
449 return;
450 }
451
452 $resolvedLogger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
453 if (is_object($resolvedLogger) && method_exists($resolvedLogger, 'warn')) {
454 $resolvedLogger->warn($message);
455 return;
456 }
457
458 abj404_logPhpFallback('service-resolution-fallback', $message);
459 }
460 }
461