| 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 |
} finally { |
| 87 |
// finally (not "after the try/catch") so the handle still |
| 88 |
// closes if a Throwable that isn't an Exception escapes the |
| 89 |
// loop -- the previous unconditional-looking cleanup below |
| 90 |
// the try/catch was skipped in that case. Same |
| 91 |
// resource-lifecycle shape as |
| 92 |
// includes/import/ImportService.php::doImportFile(). |
| 93 |
if ($handle != null) { |
| 94 |
fclose($handle); |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
} else { |
| 99 |
echo nl2br(__('(The log file does not exist.)', '404-solution')); |
| 100 |
} |
| 101 |
} |
| 102 |
|
| 103 |
/** |
| 104 |
* Display the tools page. |
| 105 |
* @return void |
| 106 |
*/ |
| 107 |
function echoAdminToolsPage() { |
| 108 |
$view = $this->view; |
| 109 |
|
| 110 |
$header = $this->tpl('viewStatsToolsPageHeader.html'); |
| 111 |
$header = $this->f->str_replace('{title}', esc_html__('Tools', '404-solution'), $header); |
| 112 |
$header = $this->f->str_replace('{expand_all}', esc_html__('Expand All', '404-solution'), $header); |
| 113 |
echo $header; |
| 114 |
|
| 115 |
$link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_exportRedirects"); |
| 116 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsExportForm.html"); |
| 117 |
$html = $this->f->str_replace('{toolsExportRedirectsLink}', $link, $html); |
| 118 |
$html = $this->f->doNormalReplacements($html); |
| 119 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-export', 'abj404-exportRedirects', __('Export', '404-solution'), $html, true, $view->getCardIcon('download'))); |
| 120 |
|
| 121 |
$link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_importRedirectsFile"); |
| 122 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsImportForm.html"); |
| 123 |
$html = $this->f->str_replace('{toolsImportRedirectsLink}', $link, $html); |
| 124 |
$html = $this->f->doNormalReplacements($html); |
| 125 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-import', 'abj404-importRedirects', __('Import', '404-solution'), $html, false, $view->getCardIcon('upload'))); |
| 126 |
|
| 127 |
$url = "?page=" . ABJ404_PP . "&subpage=abj404_tools"; |
| 128 |
$link = wp_nonce_url($url, "abj404_purgeRedirects"); |
| 129 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsPurgeForm.html"); |
| 130 |
$html = $this->f->str_replace('{toolsPurgeFormActionLink}', $link, $html); |
| 131 |
$html = $this->f->doNormalReplacements($html); |
| 132 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-purge', 'abj404-purgeRedirects', __('Purge Options', '404-solution'), $html, false, $view->getCardIcon('trash'))); |
| 133 |
|
| 134 |
$ngramLink = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_rebuildNgramCache"); |
| 135 |
$spellingLink = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_clearSpellingCache"); |
| 136 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsCacheForm.html"); |
| 137 |
$html = $this->f->str_replace('{toolsNgramCacheFormActionLink}', $ngramLink, $html); |
| 138 |
$html = $this->f->str_replace('{toolsSpellingCacheFormActionLink}', $spellingLink, $html); |
| 139 |
$html = $this->f->doNormalReplacements($html); |
| 140 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-cache', 'abj404-cacheTools', __('Cache Management', '404-solution'), $html, false, $view->getCardIcon('database'))); |
| 141 |
|
| 142 |
$html = $this->getToolsDiagnosticsMarkup(); |
| 143 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-diagnostics', 'abj404-diagnosticsTools', __('Diagnostics', '404-solution'), $html, false, $view->getCardIcon('warning'))); |
| 144 |
|
| 145 |
$link = wp_nonce_url("?page=" . ABJ404_PP . "&subpage=abj404_tools", "abj404_runMaintenance"); |
| 146 |
$link .= '&manually_fired=true'; |
| 147 |
$html = ABJ_404_Solution_FileSystemService::readFileContents(dirname(__DIR__) . "/html/toolsEtcForm.html"); |
| 148 |
$html = $this->f->str_replace('{toolsMaintenanceFormActionLink}', $link, $html); |
| 149 |
$html = $this->f->doNormalReplacements($html); |
| 150 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-etc', 'abj404-etcTools', __('Etcetera', '404-solution'), $html, false, $view->getCardIcon('cog'))); |
| 151 |
|
| 152 |
$html = $this->getMigrateFromPluginMarkup(); |
| 153 |
$view->echoOptionsSection(new ABJ_404_Solution_OptionsSectionView('tools-migrate', 'abj404-migrateFromPlugin', __('Migrate from Another Plugin', '404-solution'), $html, false, $view->getCardIcon('upload'))); |
| 154 |
|
| 155 |
echo $this->tpl('viewStatsToolsPageFooter.html'); |
| 156 |
} |
| 157 |
|
| 158 |
/** |
| 159 |
* Build the "Migrate from Another Plugin" card markup. |
| 160 |
* |
| 161 |
* @return string |
| 162 |
*/ |
| 163 |
public function getMigrateFromPluginMarkup(): string { |
| 164 |
// Use the redirects repository already injected into this component |
| 165 |
// (ViewComponent::$redirectsRepository) rather than re-resolving it from |
| 166 |
// the service container. The dependency is in hand; reaching for |
| 167 |
// abj_service() here would bypass the component's own wiring. |
| 168 |
$redirectsRepo = is_object($this->redirectsRepository) |
| 169 |
? $this->redirectsRepository |
| 170 |
: abj_service('redirects_repository'); |
| 171 |
$logger = abj_service('logging'); |
| 172 |
$importer = new ABJ_404_Solution_CrossPluginImporter($redirectsRepo, $logger); |
| 173 |
|
| 174 |
$detected = $importer->detectInstalledPlugins(); |
| 175 |
|
| 176 |
$pluginLabels = array( |
| 177 |
'rankmath' => __('Rank Math', '404-solution'), |
| 178 |
'yoast' => __('Yoast SEO Premium', '404-solution'), |
| 179 |
'aioseo' => __('AIOSEO', '404-solution'), |
| 180 |
'safe-redirect-manager' => __('Safe Redirect Manager', '404-solution'), |
| 181 |
'redirection' => __('Redirection Plugin', '404-solution'), |
| 182 |
); |
| 183 |
|
| 184 |
$availableSources = array(); |
| 185 |
foreach ($detected as $slug => $isAvailable) { |
| 186 |
if ($isAvailable) { |
| 187 |
$availableSources[$slug] = isset($pluginLabels[$slug]) ? $pluginLabels[$slug] : $slug; |
| 188 |
} |
| 189 |
} |
| 190 |
|
| 191 |
$migrateActionUrl = wp_nonce_url( |
| 192 |
'?page=' . ABJ404_PP . '&subpage=abj404_tools', |
| 193 |
'abj404_importFromPlugin' |
| 194 |
); |
| 195 |
|
| 196 |
if (empty($availableSources)) { |
| 197 |
$html = $this->tpl('viewStatsMigrateEmpty.html'); |
| 198 |
$html = $this->f->str_replace('{empty_text}', esc_html__('No supported redirect plugins detected on this site.', '404-solution'), $html); |
| 199 |
$html = $this->f->str_replace('{supported_text}', esc_html__('Supported plugins: Rank Math, Yoast SEO Premium, AIOSEO, Safe Redirect Manager, Redirection.', '404-solution'), $html); |
| 200 |
return $html; |
| 201 |
} |
| 202 |
|
| 203 |
$detectedNames = array_values($availableSources); |
| 204 |
|
| 205 |
$previewNonce = wp_create_nonce('abj404_crossPluginPreview'); |
| 206 |
$ajaxUrl = admin_url('admin-ajax.php'); |
| 207 |
|
| 208 |
$optionTpl = $this->tpl('viewStatsMigrateOption.html'); |
| 209 |
$sourceOptionsHtml = ''; |
| 210 |
foreach ($availableSources as $slug => $label) { |
| 211 |
$opt = $this->f->str_replace('{slug}', esc_attr((string)$slug), $optionTpl); |
| 212 |
$opt = $this->f->str_replace('{label}', esc_html((string)$label), $opt); |
| 213 |
$sourceOptionsHtml .= $opt; |
| 214 |
} |
| 215 |
|
| 216 |
// allow-em-dash: preserving shipped translation string verbatim (extracted, not authored, here) |
| 217 |
$msgFound = __('Found %d redirect(s) from %s — proceed with import?', '404-solution'); |
| 218 |
// Encoded through ABJ_404_Solution_JsonResponseEncoder: every value below is a |
| 219 |
// __() result, and a `gettext` filter returning bytes json_encode() cannot |
| 220 |
// represent would otherwise make this false, then '' after the cast, then a |
| 221 |
// JSON.parse('') throw in the importer's own script. |
| 222 |
$migrateConfig = ABJ_404_Solution_JsonResponseEncoder::encode(array( |
| 223 |
'ajaxUrl' => $ajaxUrl, |
| 224 |
'nonce' => $previewNonce, |
| 225 |
'msgFound' => $msgFound, |
| 226 |
'msgNone' => __('No redirects found in %s. Nothing to import.', '404-solution'), |
| 227 |
'msgError' => __('Could not fetch preview. Please try again.', '404-solution'), |
| 228 |
))->json(); |
| 229 |
|
| 230 |
$html = $this->tpl('viewStatsMigrateForm.html'); |
| 231 |
$html = $this->f->str_replace('{detected_label}', esc_html__('Detected redirect plugins:', '404-solution'), $html); |
| 232 |
$html = $this->f->str_replace('{detected_names}', esc_html(implode(', ', $detectedNames)), $html); |
| 233 |
$html = $this->f->str_replace('{source_label}', esc_html__('Source plugin:', '404-solution'), $html); |
| 234 |
$html = $this->f->str_replace('{source_options}', $sourceOptionsHtml, $html); |
| 235 |
$html = $this->f->str_replace('{preview_text}', esc_html__('Preview Import', '404-solution'), $html); |
| 236 |
$html = $this->f->str_replace('{action_url}', esc_url($migrateActionUrl), $html); |
| 237 |
$html = $this->f->str_replace('{confirm_text}', esc_attr__('Confirm Import', '404-solution'), $html); |
| 238 |
$html = $this->f->str_replace('{back_text}', esc_html__('Back', '404-solution'), $html); |
| 239 |
$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); |
| 240 |
$html = $this->f->str_replace('{migrate_config}', esc_attr($migrateConfig), $html); |
| 241 |
|
| 242 |
return $html; |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* Build compact diagnostics markup for the Tools page. |
| 247 |
* |
| 248 |
* @return string |
| 249 |
*/ |
| 250 |
public function getToolsDiagnosticsMarkup() { |
| 251 |
$rows = $this->getToolsDiagnosticsRows(); |
| 252 |
$rowTpl = $this->tpl('viewStatsDiagnosticsRow.html'); |
| 253 |
$rowsHtml = ''; |
| 254 |
|
| 255 |
foreach ($rows as $row) { |
| 256 |
$label = $this->readString($row, 'label'); |
| 257 |
$value = $this->readString($row, 'value'); |
| 258 |
$valueHtml = $this->readString($row, 'value_html'); |
| 259 |
$status = $this->readString($row, 'status'); |
| 260 |
if ($status === '') { |
| 261 |
$status = 'info'; |
| 262 |
} |
| 263 |
$statusLabel = ($status === 'ok') ? __('OK', '404-solution') : (($status === 'warn') ? __('Warning', '404-solution') : __('Info', '404-solution')); |
| 264 |
$statusClass = ($status === 'ok') ? 'abj404-pill-success' : (($status === 'warn') ? 'abj404-pill-warning' : 'abj404-pill-info'); |
| 265 |
|
| 266 |
$valueCell = ($valueHtml !== '') ? wp_kses_post($valueHtml) : esc_html($value); |
| 267 |
|
| 268 |
$rowHtml = $this->f->str_replace('{label}', esc_html($label), $rowTpl); |
| 269 |
$rowHtml = $this->f->str_replace('{value_cell}', $valueCell, $rowHtml); |
| 270 |
$rowHtml = $this->f->str_replace('{status_class}', esc_attr($statusClass), $rowHtml); |
| 271 |
$rowHtml = $this->f->str_replace('{status_label}', esc_html($statusLabel), $rowHtml); |
| 272 |
$rowsHtml .= $rowHtml; |
| 273 |
} |
| 274 |
|
| 275 |
$html = $this->tpl('viewStatsDiagnosticsTable.html'); |
| 276 |
$html = $this->f->str_replace('{intro_text}', esc_html__('Quick environment checks for troubleshooting and support.', '404-solution'), $html); |
| 277 |
$html = $this->f->str_replace('{rows}', $rowsHtml, $html); |
| 278 |
|
| 279 |
return $html; |
| 280 |
} |
| 281 |
|
| 282 |
/** |
| 283 |
* Collect diagnostics fields displayed on the Tools page. |
| 284 |
* |
| 285 |
* @return array<int, array<string, mixed>> |
| 286 |
*/ |
| 287 |
public function getToolsDiagnosticsRows() { |
| 288 |
$collector = new ABJ_404_Solution_ToolsDiagnostics(); |
| 289 |
$rows = $collector->getRows(); |
| 290 |
foreach ($rows as $index => $row) { |
| 291 |
$controls = is_array($row) && isset($row['controls']) && is_array($row['controls']) |
| 292 |
? $row['controls'] |
| 293 |
: null; |
| 294 |
if ($controls === null) { |
| 295 |
continue; |
| 296 |
} |
| 297 |
|
| 298 |
$rows[$index]['value_html'] = $this->renderDiagnosticsControls($controls); |
| 299 |
unset($rows[$index]['controls']); |
| 300 |
} |
| 301 |
|
| 302 |
return $rows; |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* @param array<mixed> $controls |
| 307 |
* @return string |
| 308 |
*/ |
| 309 |
private function renderDiagnosticsControls(array $controls): string { |
| 310 |
if (($controls['kind'] ?? '') !== 'simulated-db-latency') { |
| 311 |
return ''; |
| 312 |
} |
| 313 |
|
| 314 |
$urls = isset($controls['urls']) && is_array($controls['urls']) ? $controls['urls'] : array(); |
| 315 |
$labels = isset($controls['labels']) && is_array($controls['labels']) ? $controls['labels'] : array(); |
| 316 |
|
| 317 |
$controlsHtml = $this->tpl('viewStatsDiagnosticsLatencyControls.html'); |
| 318 |
$controlsHtml = $this->f->str_replace('{value_text}', esc_html($this->valueToString($controls['value_text'] ?? '')), $controlsHtml); |
| 319 |
$controlsHtml = $this->f->str_replace('{url_250}', esc_url($this->valueToString($urls[250] ?? '')), $controlsHtml); |
| 320 |
$controlsHtml = $this->f->str_replace('{url_500}', esc_url($this->valueToString($urls[500] ?? '')), $controlsHtml); |
| 321 |
$controlsHtml = $this->f->str_replace('{url_900}', esc_url($this->valueToString($urls[900] ?? '')), $controlsHtml); |
| 322 |
$controlsHtml = $this->f->str_replace('{url_0}', esc_url($this->valueToString($urls[0] ?? '')), $controlsHtml); |
| 323 |
$controlsHtml = $this->f->str_replace('{label_250}', esc_html($this->valueToString($labels[250] ?? '')), $controlsHtml); |
| 324 |
$controlsHtml = $this->f->str_replace('{label_500}', esc_html($this->valueToString($labels[500] ?? '')), $controlsHtml); |
| 325 |
$controlsHtml = $this->f->str_replace('{label_900}', esc_html($this->valueToString($labels[900] ?? '')), $controlsHtml); |
| 326 |
$controlsHtml = $this->f->str_replace('{label_disable}', esc_html($this->valueToString($labels[0] ?? '')), $controlsHtml); |
| 327 |
|
| 328 |
return $controlsHtml; |
| 329 |
} |
| 330 |
|
| 331 |
/** |
| 332 |
* @param array<mixed> $row |
| 333 |
* @param string $key |
| 334 |
* @return string |
| 335 |
*/ |
| 336 |
private function readString(array $row, string $key): string { |
| 337 |
return $this->valueToString($row[$key] ?? ''); |
| 338 |
} |
| 339 |
|
| 340 |
/** |
| 341 |
* @param mixed $value |
| 342 |
* @return string |
| 343 |
*/ |
| 344 |
private function valueToString($value): string { |
| 345 |
if (is_string($value)) { |
| 346 |
return $value; |
| 347 |
} |
| 348 |
if (is_int($value) || is_float($value) || is_bool($value)) { |
| 349 |
return (string)$value; |
| 350 |
} |
| 351 |
|
| 352 |
return ''; |
| 353 |
} |
| 354 |
} |
| 355 |
|