| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
// allow-no-test-found: covered through Tools page entry-point tests in tests/ViewToolsPageTest.php |
| 8 |
|
| 9 |
/** |
| 10 |
* Renders the Tools admin tab and debug-file viewer. |
| 11 |
*/ |
| 12 |
class ABJ_404_Solution_View_Tools extends ABJ_404_Solution_ViewComponent { |
| 13 |
|
| 14 |
/** |
| 15 |
* Load an HTML template from includes/html/ as a string. |
| 16 |
* |
| 17 |
* @param string $name Filename relative to includes/html/. |
| 18 |
* @return string |
| 19 |
*/ |
| 20 |
private function tpl($name) { |
| 21 |
return (string)ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . '/html/' . $name); |
| 22 |
} |
| 23 |
|
| 24 |
/** @return void */ |
| 25 |
function echoAdminDebugFile() { |
| 26 |
$isPluginAdmin = abj_service('admin_access_policy')->isPluginAdmin(); |
| 27 |
if ($isPluginAdmin) { |
| 28 |
$filesToEcho = array($this->logger->getDebugFilePath(), |
| 29 |
$this->logger->getDebugFilePathOld()); |
| 30 |
$wrapperTpl = $this->tpl('viewStatsDebugFileWrapper.html'); |
| 31 |
for ($i = 0; $i < count($filesToEcho); $i++) { |
| 32 |
$currentFile = $filesToEcho[$i]; |
| 33 |
ob_start(); |
| 34 |
$this->echoFileContents($currentFile); |
| 35 |
$contents = (string)ob_get_clean(); |
| 36 |
$row = $this->f->str_replace('{file_name}', esc_html((string)$currentFile), $wrapperTpl); |
| 37 |
$row = $this->f->str_replace('{contents}', $contents, $row); |
| 38 |
echo $row; |
| 39 |
} |
| 40 |
|
| 41 |
} else { |
| 42 |
echo "Non-admin request to view debug file."; |
| 43 |
$current_user = ABJ_404_Solution_UserRef::fromWpUser(wp_get_current_user()); |
| 44 |
$userLogin = $current_user !== null ? $current_user->getLogin() : ''; |
| 45 |
$userDisplay = $current_user !== null ? $current_user->getDisplayName() : ''; |
| 46 |
$userEmail = $current_user !== null ? $current_user->getEmail() : ''; |
| 47 |
$userId = $current_user !== null ? $current_user->getId() : 0; |
| 48 |
$userInfo = "Login: " . $userLogin . ", display name: " . |
| 49 |
$userDisplay . ", Email: " . $userEmail . |
| 50 |
", UserID: " . $userId; |
| 51 |
$this->logger->infoMessage("Non-admin request to view debug file. User info: " . |
| 52 |
$userInfo); |
| 53 |
} |
| 54 |
} |
| 55 |
|
| 56 |
/** |
| 57 |
* @param string $fileName |
| 58 |
* @return void |
| 59 |
*/ |
| 60 |
function echoFileContents($fileName) { |
| 61 |
if (!is_string($fileName)) { |
| 62 |
$fileName = ''; |
| 63 |
} |
| 64 |
|
| 65 |
if (file_exists($fileName)) { |
| 66 |
$linesRead = 0; |
| 67 |
$handle = null; |
| 68 |
try { |
| 69 |
if ($handle = fopen($fileName, "r")) { |
| 70 |
$truncatedTpl = $this->tpl('viewStatsDebugFileTruncated.html'); |
| 71 |
while (($line = fgets($handle)) !== false) { |
| 72 |
$linesRead++; |
| 73 |
echo nl2br(esc_html($line)); |
| 74 |
|
| 75 |
if ($linesRead > 1000000) { |
| 76 |
echo $this->f->str_replace('{lines_read}', esc_html((string)$linesRead), $truncatedTpl); |
| 77 |
break; |
| 78 |
} |
| 79 |
} |
| 80 |
} else { |
| 81 |
$this->logger->errorMessage("Error opening debug file."); |
| 82 |
} |
| 83 |
|
| 84 |
} catch (Exception $e) { |
| 85 |
$this->logger->errorMessage("Error while reading debug file.", $e); |
| 86 |
} |
| 87 |
|
| 88 |
if ($handle != null) { |
| 89 |
fclose($handle); |
| 90 |
} |
| 91 |
|
| 92 |
} else { |
| 93 |
echo nl2br(__('(The log file does not exist.)', '404-solution')); |
| 94 |
} |
| 95 |
} |
| 96 |
|
| 97 |
/** |
| 98 |
* Display the tools page. |
| 99 |
* @return void |
| 100 |
*/ |
| 101 |
function echoAdminToolsPage() { |
| 102 |
$view = $this->view; |
| 103 |
|
| 104 |
$header = $this->tpl('viewStatsToolsPageHeader.html'); |
| 105 |
$header = $this->f->str_replace('{title}', esc_html__('Tools', '404-solution'), $header); |
| 106 |
$header = $this->f->str_replace('{expand_all}', esc_html__('Expand All', '404-solution'), $header); |
| 107 |
echo $header; |
| 108 |
|
| 109 |
$link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_exportRedirects"); |
| 110 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsExportForm.html"); |
| 111 |
$html = $this->f->str_replace('{toolsExportRedirectsLink}', $link, $html); |
| 112 |
$html = $this->f->doNormalReplacements($html); |
| 113 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-export', 'abj404-exportRedirects', __('Export', '404-solution'), $html, true, $view->getCardIcon('download'))); |
| 114 |
|
| 115 |
$link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_importRedirectsFile"); |
| 116 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsImportForm.html"); |
| 117 |
$html = $this->f->str_replace('{toolsImportRedirectsLink}', $link, $html); |
| 118 |
$html = $this->f->doNormalReplacements($html); |
| 119 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-import', 'abj404-importRedirects', __('Import', '404-solution'), $html, false, $view->getCardIcon('upload'))); |
| 120 |
|
| 121 |
$url = "?page=" . ABJ404_PP . "&subpage=abj404_tools"; |
| 122 |
$link = wp_nonce_url($url, "abj404_purgeRedirects"); |
| 123 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsPurgeForm.html"); |
| 124 |
$html = $this->f->str_replace('{toolsPurgeFormActionLink}', $link, $html); |
| 125 |
$html = $this->f->doNormalReplacements($html); |
| 126 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-purge', 'abj404-purgeRedirects', __('Purge Options', '404-solution'), $html, false, $view->getCardIcon('trash'))); |
| 127 |
|
| 128 |
$ngramLink = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_rebuildNgramCache"); |
| 129 |
$spellingLink = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_clearSpellingCache"); |
| 130 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsCacheForm.html"); |
| 131 |
$html = $this->f->str_replace('{toolsNgramCacheFormActionLink}', $ngramLink, $html); |
| 132 |
$html = $this->f->str_replace('{toolsSpellingCacheFormActionLink}', $spellingLink, $html); |
| 133 |
$html = $this->f->doNormalReplacements($html); |
| 134 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-cache', 'abj404-cacheTools', __('Cache Management', '404-solution'), $html, false, $view->getCardIcon('database'))); |
| 135 |
|
| 136 |
$html = $this->getToolsDiagnosticsMarkup(); |
| 137 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-diagnostics', 'abj404-diagnosticsTools', __('Diagnostics', '404-solution'), $html, false, $view->getCardIcon('warning'))); |
| 138 |
|
| 139 |
$link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_runMaintenance"); |
| 140 |
$link .= '&manually_fired=true'; |
| 141 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsEtcForm.html"); |
| 142 |
$html = $this->f->str_replace('{toolsMaintenanceFormActionLink}', $link, $html); |
| 143 |
$html = $this->f->doNormalReplacements($html); |
| 144 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-etc', 'abj404-etcTools', __('Etcetera', '404-solution'), $html, false, $view->getCardIcon('cog'))); |
| 145 |
|
| 146 |
$html = $this->getMigrateFromPluginMarkup(); |
| 147 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-migrate', 'abj404-migrateFromPlugin', __('Migrate from Another Plugin', '404-solution'), $html, false, $view->getCardIcon('upload'))); |
| 148 |
|
| 149 |
echo $this->tpl('viewStatsToolsPageFooter.html'); |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Build the "Migrate from Another Plugin" card markup. |
| 154 |
* |
| 155 |
* @return string |
| 156 |
*/ |
| 157 |
public function getMigrateFromPluginMarkup(): string { |
| 158 |
// Use the redirects repository already injected into this component |
| 159 |
// (ViewComponent::$redirectsRepository) rather than re-resolving it from |
| 160 |
// the service container. The dependency is in hand; reaching for |
| 161 |
// abj_service() here would bypass the component's own wiring. |
| 162 |
$redirectsRepo = is_object($this->redirectsRepository) |
| 163 |
? $this->redirectsRepository |
| 164 |
: abj_service('redirects_repository'); |
| 165 |
$logger = abj_service('logging'); |
| 166 |
$importer = new ABJ_404_Solution_CrossPluginImporter($redirectsRepo, $logger); |
| 167 |
|
| 168 |
$detected = $importer->detectInstalledPlugins(); |
| 169 |
|
| 170 |
$pluginLabels = array( |
| 171 |
'rankmath' => __('Rank Math', '404-solution'), |
| 172 |
'yoast' => __('Yoast SEO Premium', '404-solution'), |
| 173 |
'aioseo' => __('AIOSEO', '404-solution'), |
| 174 |
'safe-redirect-manager' => __('Safe Redirect Manager', '404-solution'), |
| 175 |
'redirection' => __('Redirection Plugin', '404-solution'), |
| 176 |
); |
| 177 |
|
| 178 |
$availableSources = array(); |
| 179 |
foreach ($detected as $slug => $isAvailable) { |
| 180 |
if ($isAvailable) { |
| 181 |
$availableSources[$slug] = isset($pluginLabels[$slug]) ? $pluginLabels[$slug] : $slug; |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
$migrateActionUrl = wp_nonce_url( |
| 186 |
'?page=' . ABJ404_PP . '&subpage=abj404_tools', |
| 187 |
'abj404_importFromPlugin' |
| 188 |
); |
| 189 |
|
| 190 |
if (empty($availableSources)) { |
| 191 |
$html = $this->tpl('viewStatsMigrateEmpty.html'); |
| 192 |
$html = $this->f->str_replace('{empty_text}', esc_html__('No supported redirect plugins detected on this site.', '404-solution'), $html); |
| 193 |
$html = $this->f->str_replace('{supported_text}', esc_html__('Supported plugins: Rank Math, Yoast SEO Premium, AIOSEO, Safe Redirect Manager, Redirection.', '404-solution'), $html); |
| 194 |
return $html; |
| 195 |
} |
| 196 |
|
| 197 |
$detectedNames = array_values($availableSources); |
| 198 |
|
| 199 |
$previewNonce = wp_create_nonce('abj404_crossPluginPreview'); |
| 200 |
$ajaxUrl = admin_url('admin-ajax.php'); |
| 201 |
|
| 202 |
$optionTpl = $this->tpl('viewStatsMigrateOption.html'); |
| 203 |
$sourceOptionsHtml = ''; |
| 204 |
foreach ($availableSources as $slug => $label) { |
| 205 |
$opt = $this->f->str_replace('{slug}', esc_attr((string)$slug), $optionTpl); |
| 206 |
$opt = $this->f->str_replace('{label}', esc_html((string)$label), $opt); |
| 207 |
$sourceOptionsHtml .= $opt; |
| 208 |
} |
| 209 |
|
| 210 |
// allow-em-dash: preserving shipped translation string verbatim (extracted, not authored, here) |
| 211 |
$msgFound = __('Found %d redirect(s) from %s — proceed with import?', '404-solution'); |
| 212 |
$migrateConfig = wp_json_encode(array( |
| 213 |
'ajaxUrl' => $ajaxUrl, |
| 214 |
'nonce' => $previewNonce, |
| 215 |
'msgFound' => $msgFound, |
| 216 |
'msgNone' => __('No redirects found in %s. Nothing to import.', '404-solution'), |
| 217 |
'msgError' => __('Could not fetch preview. Please try again.', '404-solution'), |
| 218 |
)); |
| 219 |
|
| 220 |
$html = $this->tpl('viewStatsMigrateForm.html'); |
| 221 |
$html = $this->f->str_replace('{detected_label}', esc_html__('Detected redirect plugins:', '404-solution'), $html); |
| 222 |
$html = $this->f->str_replace('{detected_names}', esc_html(implode(', ', $detectedNames)), $html); |
| 223 |
$html = $this->f->str_replace('{source_label}', esc_html__('Source plugin:', '404-solution'), $html); |
| 224 |
$html = $this->f->str_replace('{source_options}', $sourceOptionsHtml, $html); |
| 225 |
$html = $this->f->str_replace('{preview_text}', esc_html__('Preview Import', '404-solution'), $html); |
| 226 |
$html = $this->f->str_replace('{action_url}', esc_url($migrateActionUrl), $html); |
| 227 |
$html = $this->f->str_replace('{confirm_text}', esc_attr__('Confirm Import', '404-solution'), $html); |
| 228 |
$html = $this->f->str_replace('{back_text}', esc_html__('Back', '404-solution'), $html); |
| 229 |
$html = $this->f->str_replace('{note_text}', esc_html__('This will import all active redirects from the selected plugin into 404 Solution. Regex and redirect codes are preserved.', '404-solution'), $html); |
| 230 |
$html = $this->f->str_replace('{migrate_config}', esc_attr((string)$migrateConfig), $html); |
| 231 |
|
| 232 |
return $html; |
| 233 |
} |
| 234 |
|
| 235 |
/** |
| 236 |
* Build compact diagnostics markup for the Tools page. |
| 237 |
* |
| 238 |
* @return string |
| 239 |
*/ |
| 240 |
public function getToolsDiagnosticsMarkup() { |
| 241 |
$rows = $this->getToolsDiagnosticsRows(); |
| 242 |
$rowTpl = $this->tpl('viewStatsDiagnosticsRow.html'); |
| 243 |
$rowsHtml = ''; |
| 244 |
|
| 245 |
foreach ($rows as $row) { |
| 246 |
$label = $this->readString($row, 'label'); |
| 247 |
$value = $this->readString($row, 'value'); |
| 248 |
$valueHtml = $this->readString($row, 'value_html'); |
| 249 |
$status = $this->readString($row, 'status'); |
| 250 |
if ($status === '') { |
| 251 |
$status = 'info'; |
| 252 |
} |
| 253 |
$statusLabel = ($status === 'ok') ? __('OK', '404-solution') : (($status === 'warn') ? __('Warning', '404-solution') : __('Info', '404-solution')); |
| 254 |
$statusClass = ($status === 'ok') ? 'abj404-pill-success' : (($status === 'warn') ? 'abj404-pill-warning' : 'abj404-pill-info'); |
| 255 |
|
| 256 |
$valueCell = ($valueHtml !== '') ? wp_kses_post($valueHtml) : esc_html($value); |
| 257 |
|
| 258 |
$rowHtml = $this->f->str_replace('{label}', esc_html($label), $rowTpl); |
| 259 |
$rowHtml = $this->f->str_replace('{value_cell}', $valueCell, $rowHtml); |
| 260 |
$rowHtml = $this->f->str_replace('{status_class}', esc_attr($statusClass), $rowHtml); |
| 261 |
$rowHtml = $this->f->str_replace('{status_label}', esc_html($statusLabel), $rowHtml); |
| 262 |
$rowsHtml .= $rowHtml; |
| 263 |
} |
| 264 |
|
| 265 |
$html = $this->tpl('viewStatsDiagnosticsTable.html'); |
| 266 |
$html = $this->f->str_replace('{intro_text}', esc_html__('Quick environment checks for troubleshooting and support.', '404-solution'), $html); |
| 267 |
$html = $this->f->str_replace('{rows}', $rowsHtml, $html); |
| 268 |
|
| 269 |
return $html; |
| 270 |
} |
| 271 |
|
| 272 |
/** |
| 273 |
* Collect diagnostics fields displayed on the Tools page. |
| 274 |
* |
| 275 |
* @return array<int, array<string, mixed>> |
| 276 |
*/ |
| 277 |
public function getToolsDiagnosticsRows() { |
| 278 |
$collector = new ABJ_404_Solution_ToolsDiagnostics(); |
| 279 |
$rows = $collector->getRows(); |
| 280 |
foreach ($rows as $index => $row) { |
| 281 |
$controls = is_array($row) && isset($row['controls']) && is_array($row['controls']) |
| 282 |
? $row['controls'] |
| 283 |
: null; |
| 284 |
if ($controls === null) { |
| 285 |
continue; |
| 286 |
} |
| 287 |
|
| 288 |
$rows[$index]['value_html'] = $this->renderDiagnosticsControls($controls); |
| 289 |
unset($rows[$index]['controls']); |
| 290 |
} |
| 291 |
|
| 292 |
return $rows; |
| 293 |
} |
| 294 |
|
| 295 |
/** |
| 296 |
* @param array<mixed> $controls |
| 297 |
* @return string |
| 298 |
*/ |
| 299 |
private function renderDiagnosticsControls(array $controls): string { |
| 300 |
if (($controls['kind'] ?? '') !== 'simulated-db-latency') { |
| 301 |
return ''; |
| 302 |
} |
| 303 |
|
| 304 |
$urls = isset($controls['urls']) && is_array($controls['urls']) ? $controls['urls'] : array(); |
| 305 |
$labels = isset($controls['labels']) && is_array($controls['labels']) ? $controls['labels'] : array(); |
| 306 |
|
| 307 |
$controlsHtml = $this->tpl('viewStatsDiagnosticsLatencyControls.html'); |
| 308 |
$controlsHtml = $this->f->str_replace('{value_text}', esc_html($this->valueToString($controls['value_text'] ?? '')), $controlsHtml); |
| 309 |
$controlsHtml = $this->f->str_replace('{url_250}', esc_url($this->valueToString($urls[250] ?? '')), $controlsHtml); |
| 310 |
$controlsHtml = $this->f->str_replace('{url_500}', esc_url($this->valueToString($urls[500] ?? '')), $controlsHtml); |
| 311 |
$controlsHtml = $this->f->str_replace('{url_900}', esc_url($this->valueToString($urls[900] ?? '')), $controlsHtml); |
| 312 |
$controlsHtml = $this->f->str_replace('{url_0}', esc_url($this->valueToString($urls[0] ?? '')), $controlsHtml); |
| 313 |
$controlsHtml = $this->f->str_replace('{label_250}', esc_html($this->valueToString($labels[250] ?? '')), $controlsHtml); |
| 314 |
$controlsHtml = $this->f->str_replace('{label_500}', esc_html($this->valueToString($labels[500] ?? '')), $controlsHtml); |
| 315 |
$controlsHtml = $this->f->str_replace('{label_900}', esc_html($this->valueToString($labels[900] ?? '')), $controlsHtml); |
| 316 |
$controlsHtml = $this->f->str_replace('{label_disable}', esc_html($this->valueToString($labels[0] ?? '')), $controlsHtml); |
| 317 |
|
| 318 |
return $controlsHtml; |
| 319 |
} |
| 320 |
|
| 321 |
/** |
| 322 |
* @param array<mixed> $row |
| 323 |
* @param string $key |
| 324 |
* @return string |
| 325 |
*/ |
| 326 |
private function readString(array $row, string $key): string { |
| 327 |
return $this->valueToString($row[$key] ?? ''); |
| 328 |
} |
| 329 |
|
| 330 |
/** |
| 331 |
* @param mixed $value |
| 332 |
* @return string |
| 333 |
*/ |
| 334 |
private function valueToString($value): string { |
| 335 |
if (is_string($value)) { |
| 336 |
return $value; |
| 337 |
} |
| 338 |
if (is_int($value) || is_float($value) || is_bool($value)) { |
| 339 |
return (string)$value; |
| 340 |
} |
| 341 |
|
| 342 |
return ''; |
| 343 |
} |
| 344 |
} |
| 345 |
|