| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Handles redirect CSV import/export behavior. |
| 9 |
* |
| 10 |
* Kept intentionally focused so PluginLogic stays readable. |
| 11 |
*/ |
| 12 |
class ABJ_404_Solution_ImportExportService { |
| 13 |
|
| 14 |
/** |
| 15 |
* Option key that stores resumable-import progress. Keyed by sha256 |
| 16 |
* content hash of the uploaded CSV so a different file (or a different |
| 17 |
* version of the same file) does not falsely resume from stale state. |
| 18 |
* See `doImportFile()` and the timeout-resume contract in |
| 19 |
* tests/ImportExportTimeoutResumeTest.php. |
| 20 |
*/ |
| 21 |
const IMPORT_PROGRESS_OPTION = 'abj404_import_progress'; |
| 22 |
|
| 23 |
/** |
| 24 |
* Persist progress every N data rows so a hard PHP timeout (where no |
| 25 |
* exception can be caught) still leaves a usable checkpoint. Trade-off: |
| 26 |
* higher N is fewer option-writes but loses more rows on hard kill; lower |
| 27 |
* N writes more but keeps the resume window tight. 50 keeps writes |
| 28 |
* around once per second at typical row-processing rates. |
| 29 |
*/ |
| 30 |
const IMPORT_PROGRESS_CHECKPOINT_INTERVAL = 50; |
| 31 |
|
| 32 |
/** @var ABJ_404_Solution_ViewReadServiceInterface */ |
| 33 |
private $viewReadService; |
| 34 |
|
| 35 |
/** @var ABJ_404_Solution_RedirectsRepositoryInterface */ |
| 36 |
private $redirectsRepository; |
| 37 |
|
| 38 |
/** @var ABJ_404_Solution_ContentRepositoryInterface */ |
| 39 |
private $contentRepository; |
| 40 |
|
| 41 |
/** @var ABJ_404_Solution_Logging */ |
| 42 |
private $logger; |
| 43 |
|
| 44 |
/** |
| 45 |
* Constructor supports two signatures for backward compatibility: |
| 46 |
* (1) New: (ViewReadService, RedirectsRepository, ContentRepository, Logging) |
| 47 |
* (2) Legacy: (DataAccess, Logging) -- DataAccess delegates to modules |
| 48 |
* |
| 49 |
* @param mixed $viewReadServiceOrDataAccess ViewReadService or legacy DataAccess facade |
| 50 |
* @param mixed $redirectsRepoOrLogging RedirectsRepository or legacy Logging |
| 51 |
* @param ABJ_404_Solution_ContentRepositoryInterface|null $contentRepository |
| 52 |
* @param ABJ_404_Solution_Logging|null $logging |
| 53 |
*/ |
| 54 |
function __construct($viewReadServiceOrDataAccess, $redirectsRepoOrLogging, $contentRepository = null, $logging = null) { |
| 55 |
if ($contentRepository === null && $logging === null) { |
| 56 |
// Legacy 2-arg signature: (DataAccess, Logging) |
| 57 |
// DataAccess is a facade that delegates to the modules, so |
| 58 |
// reuse the same object for all three module interfaces. |
| 59 |
/** @var ABJ_404_Solution_ViewReadServiceInterface&ABJ_404_Solution_RedirectsRepositoryInterface&ABJ_404_Solution_ContentRepositoryInterface $viewReadServiceOrDataAccess */ |
| 60 |
$this->viewReadService = $viewReadServiceOrDataAccess; |
| 61 |
$this->redirectsRepository = $viewReadServiceOrDataAccess; |
| 62 |
$this->contentRepository = $viewReadServiceOrDataAccess; |
| 63 |
/** @var ABJ_404_Solution_Logging $redirectsRepoOrLogging */ |
| 64 |
$this->logger = $redirectsRepoOrLogging; |
| 65 |
} else { |
| 66 |
// New 4-arg signature |
| 67 |
/** @var ABJ_404_Solution_ViewReadServiceInterface $viewReadServiceOrDataAccess */ |
| 68 |
$this->viewReadService = $viewReadServiceOrDataAccess; |
| 69 |
/** @var ABJ_404_Solution_RedirectsRepositoryInterface $redirectsRepoOrLogging */ |
| 70 |
$this->redirectsRepository = $redirectsRepoOrLogging; |
| 71 |
/** @var ABJ_404_Solution_ContentRepositoryInterface $contentRepository */ |
| 72 |
$this->contentRepository = $contentRepository; |
| 73 |
/** @var ABJ_404_Solution_Logging $logging */ |
| 74 |
$this->logger = $logging; |
| 75 |
} |
| 76 |
} |
| 77 |
|
| 78 |
/** |
| 79 |
* @param string $format |
| 80 |
* @return string |
| 81 |
*/ |
| 82 |
function getExportFilename($format = 'native') { |
| 83 |
if ($format === 'redirection') { |
| 84 |
return abj404_getUploadsDir() . 'export-redirection.csv'; |
| 85 |
} |
| 86 |
return abj404_getUploadsDir() . 'export.csv'; |
| 87 |
} |
| 88 |
|
| 89 |
/** @return void */ |
| 90 |
function doExport() { |
| 91 |
$format = isset($_REQUEST['export_format']) ? sanitize_text_field((string)$_REQUEST['export_format']) : 'native'; |
| 92 |
|
| 93 |
$serverFormats = array('htaccess', 'nginx', 'cloudflare', 'netlify', 'vercel'); |
| 94 |
if (in_array($format, $serverFormats, true)) { |
| 95 |
$this->doServerFormatExport($format); |
| 96 |
return; |
| 97 |
} |
| 98 |
|
| 99 |
$tempFile = $this->getExportFilename($format); |
| 100 |
|
| 101 |
if ($format === 'redirection') { |
| 102 |
$nativeExportFile = $this->getExportFilename('native'); |
| 103 |
$this->viewReadService->doRedirectsExport($nativeExportFile); |
| 104 |
$error = $this->convertExportCsvToRedirectionFormat($nativeExportFile, $tempFile); |
| 105 |
if ($error !== '') { |
| 106 |
$this->logger->warn($error); |
| 107 |
return; |
| 108 |
} |
| 109 |
} else { |
| 110 |
$this->viewReadService->doRedirectsExport($tempFile); |
| 111 |
} |
| 112 |
|
| 113 |
if (file_exists($tempFile)) { |
| 114 |
header('Content-Description: File Transfer'); |
| 115 |
header('Content-Disposition: attachment; filename=' . basename($tempFile)); |
| 116 |
header('Expires: 0'); |
| 117 |
header('Cache-Control: must-revalidate'); |
| 118 |
header('Pragma: public'); |
| 119 |
header('Content-Length: ' . filesize($tempFile)); |
| 120 |
header('Content-Type: text/csv; charset=utf-8'); |
| 121 |
readfile($tempFile); |
| 122 |
exit(); |
| 123 |
} |
| 124 |
|
| 125 |
$this->logger->infoMessage("I don't see any data to export."); |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Serve a server-level or edge/CDN format export directly (no temp file needed). |
| 130 |
* |
| 131 |
* @param string $format One of: htaccess, nginx, cloudflare, netlify, vercel. |
| 132 |
* @return void |
| 133 |
*/ |
| 134 |
private function doServerFormatExport($format) { |
| 135 |
switch ($format) { |
| 136 |
case 'htaccess': |
| 137 |
$content = $this->generateHtaccessRules(); |
| 138 |
$filename = 'redirects.htaccess'; |
| 139 |
$mime = 'text/plain; charset=utf-8'; |
| 140 |
break; |
| 141 |
case 'nginx': |
| 142 |
$content = $this->generateNginxRules(); |
| 143 |
$filename = 'redirects-nginx.conf'; |
| 144 |
$mime = 'text/plain; charset=utf-8'; |
| 145 |
break; |
| 146 |
case 'cloudflare': |
| 147 |
$content = $this->generateCloudflareWorkerScript(); |
| 148 |
$filename = 'redirects-worker.js'; |
| 149 |
$mime = 'application/javascript; charset=utf-8'; |
| 150 |
break; |
| 151 |
case 'netlify': |
| 152 |
$content = $this->generateNetlifyRedirects(); |
| 153 |
$filename = '_redirects'; |
| 154 |
$mime = 'text/plain; charset=utf-8'; |
| 155 |
break; |
| 156 |
case 'vercel': |
| 157 |
$content = $this->generateVercelRedirects(); |
| 158 |
$filename = 'vercel-redirects.json'; |
| 159 |
$mime = 'application/json; charset=utf-8'; |
| 160 |
break; |
| 161 |
default: |
| 162 |
$this->logger->warn('Unknown server export format: ' . $format); |
| 163 |
return; |
| 164 |
} |
| 165 |
|
| 166 |
header('Content-Description: File Transfer'); |
| 167 |
header('Content-Disposition: attachment; filename=' . $filename); |
| 168 |
header('Expires: 0'); |
| 169 |
header('Cache-Control: must-revalidate'); |
| 170 |
header('Pragma: public'); |
| 171 |
header('Content-Length: ' . strlen($content)); |
| 172 |
header('Content-Type: ' . $mime); |
| 173 |
echo $content; |
| 174 |
exit(); |
| 175 |
} |
| 176 |
|
| 177 |
/** |
| 178 |
* Fetch all exportable (manual + regex, non-trashed) redirects and resolve |
| 179 |
* destination URLs. |
| 180 |
* |
| 181 |
* Each returned element has: |
| 182 |
* source string The from-URL stored in the DB (relative path or full URL). |
| 183 |
* dest string Resolved destination URL or path. |
| 184 |
* code int HTTP status code (301, 302, 410, …). |
| 185 |
* is_regex bool Whether this is a regex redirect. |
| 186 |
* |
| 187 |
* @return array<int, array{source: string, dest: string, code: int, is_regex: bool}> |
| 188 |
*/ |
| 189 |
function getExportableRedirects() { |
| 190 |
$dbCore = abj_service('db_core'); |
| 191 |
$redirectsTable = $dbCore->doTableNameReplacements('{wp_abj404_redirects}'); |
| 192 |
$cacheTable = $dbCore->doTableNameReplacements('{wp_abj404_permalink_cache}'); |
| 193 |
|
| 194 |
$manualStatus = defined('ABJ404_STATUS_MANUAL') ? (int)ABJ404_STATUS_MANUAL : 1; |
| 195 |
$regexStatus = defined('ABJ404_STATUS_REGEX') ? (int)ABJ404_STATUS_REGEX : 6; |
| 196 |
$typeExternal = defined('ABJ404_TYPE_EXTERNAL') ? (int)ABJ404_TYPE_EXTERNAL : 4; |
| 197 |
$typeHome = defined('ABJ404_TYPE_HOME') ? (int)ABJ404_TYPE_HOME : 5; |
| 198 |
|
| 199 |
$queryResult = $dbCore->queryAndGetResults( |
| 200 |
"SELECT r.url, r.status, r.type, r.final_dest, r.code, r.disabled, |
| 201 |
pc.url AS cached_url |
| 202 |
FROM {$redirectsTable} r |
| 203 |
LEFT JOIN {$cacheTable} pc ON r.final_dest = pc.id |
| 204 |
WHERE r.status IN (%d, %d) |
| 205 |
AND (r.disabled IS NULL OR r.disabled = 0) |
| 206 |
AND r.url IS NOT NULL AND r.url != '' |
| 207 |
ORDER BY r.url", |
| 208 |
['query_params' => [$manualStatus, $regexStatus]] |
| 209 |
); |
| 210 |
|
| 211 |
$rows = $queryResult['rows'] ?? []; |
| 212 |
if (!is_array($rows) || empty($rows)) { |
| 213 |
return array(); |
| 214 |
} |
| 215 |
|
| 216 |
$result = array(); |
| 217 |
foreach ($rows as $row) { |
| 218 |
if (!is_array($row)) { |
| 219 |
continue; |
| 220 |
} |
| 221 |
|
| 222 |
$source = isset($row['url']) ? (string)$row['url'] : ''; |
| 223 |
$isRegex = (isset($row['status']) && (int)$row['status'] === $regexStatus); |
| 224 |
$code = isset($row['code']) ? (int)$row['code'] : 301; |
| 225 |
$type = isset($row['type']) ? (int)$row['type'] : 0; |
| 226 |
$finalDest = isset($row['final_dest']) ? (string)$row['final_dest'] : ''; |
| 227 |
|
| 228 |
// Resolve destination |
| 229 |
if ($code === 410 || $code === 451) { |
| 230 |
$dest = $source; |
| 231 |
} elseif (!empty($row['cached_url'])) { |
| 232 |
$dest = (string)$row['cached_url']; |
| 233 |
} elseif ($type === $typeExternal) { |
| 234 |
$dest = $finalDest; |
| 235 |
} elseif ($type === $typeHome) { |
| 236 |
$dest = function_exists('home_url') ? home_url('/') : '/'; |
| 237 |
} elseif (is_numeric($finalDest) && (int)$finalDest > 0) { |
| 238 |
// Post/page/term ID — try get_permalink |
| 239 |
if (function_exists('get_permalink')) { |
| 240 |
$url = get_permalink((int)$finalDest); |
| 241 |
$dest = ($url !== false && is_string($url)) ? $url : ('/?p=' . $finalDest); |
| 242 |
} else { |
| 243 |
$dest = '/?p=' . $finalDest; |
| 244 |
} |
| 245 |
} elseif ($finalDest !== '') { |
| 246 |
$dest = $finalDest; |
| 247 |
} else { |
| 248 |
// No destination — skip |
| 249 |
continue; |
| 250 |
} |
| 251 |
|
| 252 |
$result[] = array( |
| 253 |
'source' => $source, |
| 254 |
'dest' => $dest, |
| 255 |
'code' => $code, |
| 256 |
'is_regex' => $isRegex, |
| 257 |
); |
| 258 |
} |
| 259 |
|
| 260 |
return $result; |
| 261 |
} |
| 262 |
|
| 263 |
/** |
| 264 |
* Generate Apache .htaccess redirect rules. |
| 265 |
* |
| 266 |
* @return string |
| 267 |
*/ |
| 268 |
function generateHtaccessRules() { |
| 269 |
$redirects = $this->getExportableRedirects(); |
| 270 |
$lines = array('# 404 Solution redirects', 'RewriteEngine On', ''); |
| 271 |
|
| 272 |
foreach ($redirects as $r) { |
| 273 |
$source = $r['source']; |
| 274 |
$dest = $r['dest']; |
| 275 |
$code = $r['code']; |
| 276 |
|
| 277 |
// Strip leading slash for RewriteRule pattern (anchored with ^) |
| 278 |
$pattern = ltrim($source, '/'); |
| 279 |
|
| 280 |
if (!$r['is_regex']) { |
| 281 |
// Escape regex metacharacters in literal paths |
| 282 |
$pattern = preg_quote($pattern, '/'); |
| 283 |
$pattern = $pattern . '/?'; |
| 284 |
} |
| 285 |
|
| 286 |
if ($code === 410 || $code === 451) { |
| 287 |
// Apache [G] flag sends a 410 Gone response; it is the closest equivalent for 451. |
| 288 |
$lines[] = 'RewriteRule ^' . $pattern . '$ - [G,L]'; |
| 289 |
} elseif ($code === 0) { |
| 290 |
// Meta Refresh requires serving an HTML response — not supported in .htaccess. |
| 291 |
$lines[] = '# Meta Refresh: ' . $source . ' → ' . $dest . ' (serve HTML; not representable as a RewriteRule)'; |
| 292 |
} else { |
| 293 |
$flag = ($code === 301) ? 'R=301' : 'R=' . $code; |
| 294 |
$lines[] = 'RewriteRule ^' . $pattern . '$ ' . $dest . ' [' . $flag . ',L]'; |
| 295 |
} |
| 296 |
} |
| 297 |
|
| 298 |
if (count($redirects) === 0) { |
| 299 |
$lines[] = '# No manual redirects found.'; |
| 300 |
} |
| 301 |
|
| 302 |
return implode("\n", $lines) . "\n"; |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Generate Nginx location block redirect rules. |
| 307 |
* |
| 308 |
* @return string |
| 309 |
*/ |
| 310 |
function generateNginxRules() { |
| 311 |
$redirects = $this->getExportableRedirects(); |
| 312 |
$lines = array('# 404 Solution redirects', ''); |
| 313 |
|
| 314 |
foreach ($redirects as $r) { |
| 315 |
$source = $r['source']; |
| 316 |
$dest = $r['dest']; |
| 317 |
$code = $r['code']; |
| 318 |
|
| 319 |
if ($r['is_regex']) { |
| 320 |
$directive = 'location ~* ' . $source; |
| 321 |
} else { |
| 322 |
$directive = 'location = ' . $source; |
| 323 |
} |
| 324 |
|
| 325 |
if ($code === 410 || $code === 451) { |
| 326 |
$lines[] = $directive . ' { return ' . $code . '; }'; |
| 327 |
} elseif ($code === 0) { |
| 328 |
// Meta Refresh requires serving an HTML response — not representable as a return directive. |
| 329 |
$lines[] = '# Meta Refresh: ' . $source . ' → ' . $dest . ' (serve HTML; not representable as a return directive)'; |
| 330 |
} else { |
| 331 |
$lines[] = $directive . ' { return ' . $code . ' ' . $dest . '; }'; |
| 332 |
} |
| 333 |
} |
| 334 |
|
| 335 |
if (count($redirects) === 0) { |
| 336 |
$lines[] = '# No manual redirects found.'; |
| 337 |
} |
| 338 |
|
| 339 |
return implode("\n", $lines) . "\n"; |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* Generate a Cloudflare Workers JavaScript snippet for handling redirects. |
| 344 |
* |
| 345 |
* @return string |
| 346 |
*/ |
| 347 |
function generateCloudflareWorkerScript() { |
| 348 |
$redirects = $this->getExportableRedirects(); |
| 349 |
|
| 350 |
$entries = array(); |
| 351 |
foreach ($redirects as $r) { |
| 352 |
$jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; |
| 353 |
$sourceJson = json_encode($r['source'], $jsonFlags); |
| 354 |
$destJson = json_encode($r['dest'], $jsonFlags); |
| 355 |
// Fall back to UTF-8 sanitization if json_encode fails on invalid bytes |
| 356 |
if ($sourceJson === false) { |
| 357 |
$sourceJson = json_encode(mb_convert_encoding($r['source'], 'UTF-8', 'UTF-8'), $jsonFlags); |
| 358 |
} |
| 359 |
if ($destJson === false) { |
| 360 |
$destJson = json_encode(mb_convert_encoding($r['dest'], 'UTF-8', 'UTF-8'), $jsonFlags); |
| 361 |
} |
| 362 |
if ($sourceJson === false || $destJson === false) { |
| 363 |
continue; |
| 364 |
} |
| 365 |
$code = (int)$r['code']; |
| 366 |
$entries[] = " " . $sourceJson . ": { dest: " . $destJson . ", status: " . $code . " }"; |
| 367 |
} |
| 368 |
|
| 369 |
$map = implode(",\n", $entries); |
| 370 |
|
| 371 |
$script = "const REDIRECTS = {\n"; |
| 372 |
$script .= ($map !== '' ? $map . "\n" : ''); |
| 373 |
$script .= "};\n"; |
| 374 |
$script .= "\n"; |
| 375 |
$script .= "addEventListener('fetch', event => {\n"; |
| 376 |
$script .= " event.respondWith(handleRequest(event.request));\n"; |
| 377 |
$script .= "});\n"; |
| 378 |
$script .= "\n"; |
| 379 |
$script .= "async function handleRequest(request) {\n"; |
| 380 |
$script .= " const url = new URL(request.url);\n"; |
| 381 |
$script .= " const rule = REDIRECTS[url.pathname] || REDIRECTS[url.pathname.replace(/\\/$/, '')];\n"; |
| 382 |
$script .= " if (rule) {\n"; |
| 383 |
$script .= " if (rule.status === 410 || rule.status === 451) return new Response(null, { status: rule.status });\n"; |
| 384 |
$script .= " if (rule.status === 0) return new Response('<meta http-equiv=\"refresh\" content=\"0;url=' + rule.dest + '\">', { status: 200, headers: { 'Content-Type': 'text/html' } });\n"; |
| 385 |
$script .= " return Response.redirect(rule.dest.startsWith('http') ? rule.dest : url.origin + rule.dest, rule.status);\n"; |
| 386 |
$script .= " }\n"; |
| 387 |
$script .= " return fetch(request);\n"; |
| 388 |
$script .= "}\n"; |
| 389 |
|
| 390 |
return $script; |
| 391 |
} |
| 392 |
|
| 393 |
/** |
| 394 |
* Generate a Netlify _redirects file. |
| 395 |
* |
| 396 |
* @return string |
| 397 |
*/ |
| 398 |
function generateNetlifyRedirects() { |
| 399 |
$redirects = $this->getExportableRedirects(); |
| 400 |
$lines = array('# 404 Solution redirects'); |
| 401 |
|
| 402 |
foreach ($redirects as $r) { |
| 403 |
$source = $r['source']; |
| 404 |
$dest = $r['dest']; |
| 405 |
$code = (int)$r['code']; |
| 406 |
$lines[] = $source . ' ' . $dest . ' ' . $code; |
| 407 |
} |
| 408 |
|
| 409 |
if (count($redirects) === 0) { |
| 410 |
$lines[] = '# No manual redirects found.'; |
| 411 |
} |
| 412 |
|
| 413 |
return implode("\n", $lines) . "\n"; |
| 414 |
} |
| 415 |
|
| 416 |
/** |
| 417 |
* Generate a Vercel redirects JSON array (for use in vercel.json). |
| 418 |
* |
| 419 |
* Note: Vercel does not natively support 410 Gone responses; those redirects |
| 420 |
* are omitted from this export. |
| 421 |
* |
| 422 |
* @return string JSON array string. |
| 423 |
*/ |
| 424 |
function generateVercelRedirects() { |
| 425 |
$redirects = $this->getExportableRedirects(); |
| 426 |
$entries = array(); |
| 427 |
|
| 428 |
foreach ($redirects as $r) { |
| 429 |
if ($r['code'] === 410 || $r['code'] === 451 || $r['code'] === 0) { |
| 430 |
// Vercel has no native 410/451/meta-refresh support; skip. |
| 431 |
continue; |
| 432 |
} |
| 433 |
$entries[] = array( |
| 434 |
'source' => $r['source'], |
| 435 |
'destination' => $r['dest'], |
| 436 |
'permanent' => ($r['code'] === 301), |
| 437 |
); |
| 438 |
} |
| 439 |
|
| 440 |
$encoded = json_encode($entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); |
| 441 |
return is_string($encoded) ? $encoded : '[]'; |
| 442 |
} |
| 443 |
|
| 444 |
/** |
| 445 |
* Convert native export format to a Redirection-compatible CSV shape. |
| 446 |
* |
| 447 |
* @param string $sourceFile Native export file path. |
| 448 |
* @param string $destinationFile Output file path. |
| 449 |
* @return string Empty string on success, error message otherwise. |
| 450 |
*/ |
| 451 |
function convertExportCsvToRedirectionFormat($sourceFile, $destinationFile) { |
| 452 |
if (!file_exists($sourceFile)) { |
| 453 |
return __('Error: Native export file does not exist.', '404-solution'); |
| 454 |
} |
| 455 |
|
| 456 |
$in = fopen($sourceFile, 'r'); |
| 457 |
if ($in === false) { |
| 458 |
return __('Error: Could not read native export file.', '404-solution'); |
| 459 |
} |
| 460 |
|
| 461 |
$out = fopen($destinationFile, 'w'); |
| 462 |
if ($out === false) { |
| 463 |
fclose($in); |
| 464 |
return __('Error: Could not create Redirection export file.', '404-solution'); |
| 465 |
} |
| 466 |
|
| 467 |
fputcsv($out, array('source', 'target', 'regex', 'code'), ',', '"', '\\'); |
| 468 |
fgetcsv($in, 0, ',', '"', '\\'); |
| 469 |
while (($row = fgetcsv($in, 0, ',', '"', '\\')) !== false) { |
| 470 |
if (!is_array($row) || count($row) < 4) { |
| 471 |
continue; |
| 472 |
} |
| 473 |
$from = trim((string)$row[0]); |
| 474 |
$status = trim((string)$row[1]); |
| 475 |
$to = trim((string)$row[3]); |
| 476 |
if ($from === '' || $to === '') { |
| 477 |
continue; |
| 478 |
} |
| 479 |
|
| 480 |
$regexFlag = (strtolower($status) === 'regex') ? '1' : '0'; |
| 481 |
$code = isset($row[6]) ? trim((string)$row[6]) : '301'; |
| 482 |
if ($code === '' || !is_numeric($code)) { |
| 483 |
$code = '301'; |
| 484 |
} |
| 485 |
fputcsv($out, array($from, $to, $regexFlag, $code), ',', '"', '\\'); |
| 486 |
} |
| 487 |
|
| 488 |
fclose($in); |
| 489 |
fclose($out); |
| 490 |
return ''; |
| 491 |
} |
| 492 |
|
| 493 |
|
| 494 |
/** |
| 495 |
* Validate the uploaded import file (extension, size, MIME type). |
| 496 |
* |
| 497 |
* @return string Empty on success, error message on failure. |
| 498 |
*/ |
| 499 |
private function validateImportFile(): string { |
| 500 |
$allowed_extensions = array('csv', 'txt'); |
| 501 |
$file_ext = strtolower(pathinfo($_FILES['import_file']['name'], PATHINFO_EXTENSION)); |
| 502 |
if (!in_array($file_ext, $allowed_extensions)) { |
| 503 |
return __('Error: Invalid file type. Only CSV/TXT files are allowed.', '404-solution'); |
| 504 |
} |
| 505 |
|
| 506 |
$max_file_size = 5 * 1024 * 1024; |
| 507 |
if ($_FILES['import_file']['size'] > $max_file_size) { |
| 508 |
return __('Error: File too large. Maximum size is 5MB.', '404-solution'); |
| 509 |
} |
| 510 |
|
| 511 |
$allowed_mime_types = array('text/csv', 'text/plain', 'application/csv', 'text/comma-separated-values', 'application/vnd.ms-excel'); |
| 512 |
$finfo = finfo_open(FILEINFO_MIME_TYPE); |
| 513 |
if ($finfo === false) { |
| 514 |
return __('Error: Unable to determine file type.', '404-solution'); |
| 515 |
} |
| 516 |
$mime_type = finfo_file($finfo, $_FILES['import_file']['tmp_name']); |
| 517 |
if (!in_array($mime_type, $allowed_mime_types)) { |
| 518 |
return __('Error: Invalid file type. Only CSV files are allowed.', '404-solution'); |
| 519 |
} |
| 520 |
|
| 521 |
return ''; |
| 522 |
} |
| 523 |
/** |
| 524 |
* Expected formats: |
| 525 |
* - from_url,status,type,to_url,wp_type |
| 526 |
* - from_url,to_url |
| 527 |
* |
| 528 |
* @return string |
| 529 |
*/ |
| 530 |
function doImportFile() { |
| 531 |
$anyIssuesToNote = array(); |
| 532 |
if (!isset($_FILES['import_file']) || $_FILES['import_file']['error'] != UPLOAD_ERR_OK) { |
| 533 |
return __('File upload error.', '404-solution'); |
| 534 |
} |
| 535 |
|
| 536 |
$dryRun = isset($_POST['dry_run']) && sanitize_text_field((string)$_POST['dry_run']) === '1'; |
| 537 |
$overwriteExisting = isset($_POST['overwrite_existing']) && sanitize_text_field((string)$_POST['overwrite_existing']) === '1'; |
| 538 |
$processedRows = 0; |
| 539 |
$validRows = 0; |
| 540 |
$invalidRows = 0; |
| 541 |
$overwrittenRows = 0; |
| 542 |
|
| 543 |
$validationError = $this->validateImportFile(); |
| 544 |
if ($validationError !== '') { |
| 545 |
return $validationError; |
| 546 |
} |
| 547 |
|
| 548 |
$file_handle = fopen($_FILES['import_file']['tmp_name'], 'r'); |
| 549 |
if (!$file_handle) { |
| 550 |
return __('Error opening the file.', '404-solution'); |
| 551 |
} |
| 552 |
|
| 553 |
// Resume support: content-hash-keyed checkpoint. If a prior import |
| 554 |
// of the SAME file content (same sha256) paused mid-stream, pick up |
| 555 |
// at the recorded row count instead of restarting from row 1. |
| 556 |
// Dry runs never write to the DB, so they never resume / persist. |
| 557 |
$hashResult = hash_file('sha256', $_FILES['import_file']['tmp_name']); |
| 558 |
$contentHash = ($dryRun || !is_string($hashResult)) ? '' : $hashResult; |
| 559 |
$resumeFromDataRow = 0; |
| 560 |
if (!$dryRun) { |
| 561 |
$existingProgress = $this->getResumeProgress($contentHash); |
| 562 |
if ($existingProgress !== null) { |
| 563 |
$resumeFromDataRow = self::progressInt($existingProgress, 'rows_processed', 0); |
| 564 |
$processedRows = self::progressInt($existingProgress, 'processed_count', $resumeFromDataRow); |
| 565 |
$validRows = self::progressInt($existingProgress, 'valid_count', 0); |
| 566 |
$invalidRows = self::progressInt($existingProgress, 'invalid_count', 0); |
| 567 |
$overwrittenRows = self::progressInt($existingProgress, 'overwritten_count', 0); |
| 568 |
if (isset($existingProgress['issues']) && is_array($existingProgress['issues'])) { |
| 569 |
/** @var array<int, string> $persistedIssues */ |
| 570 |
$persistedIssues = $existingProgress['issues']; |
| 571 |
$anyIssuesToNote = $persistedIssues; |
| 572 |
} |
| 573 |
} |
| 574 |
} |
| 575 |
|
| 576 |
$delimiter = $this->detectCsvDelimiterFromFile($file_handle); |
| 577 |
rewind($file_handle); |
| 578 |
|
| 579 |
$headerColumns = null; |
| 580 |
$dataRowsSeen = 0; |
| 581 |
while (($row = fgetcsv($file_handle, 0, $delimiter, '"', '\\')) !== false) { |
| 582 |
$data = array_map(function($v) { |
| 583 |
return trim((string)$v); |
| 584 |
}, $row); |
| 585 |
|
| 586 |
if (count($data) === 1 && $data[0] === '') { |
| 587 |
continue; |
| 588 |
} |
| 589 |
|
| 590 |
if ($headerColumns === null && $this->isCompatibleImportHeaderRow($data)) { |
| 591 |
$headerColumns = $this->normalizeImportHeaders($data); |
| 592 |
continue; |
| 593 |
} |
| 594 |
|
| 595 |
// Resume: skip data rows already processed in a prior (paused) run. |
| 596 |
// The header has just been parsed above, so the skip applies only |
| 597 |
// to data rows (the unit the resume counter is keyed on). |
| 598 |
if ($dataRowsSeen < $resumeFromDataRow) { |
| 599 |
$dataRowsSeen++; |
| 600 |
continue; |
| 601 |
} |
| 602 |
|
| 603 |
if ($headerColumns !== null) { |
| 604 |
$dataArray = $this->mapImportRowByHeaders($data, $headerColumns); |
| 605 |
} else { |
| 606 |
$dataArray = $this->mapImportRowWithoutHeaders($data); |
| 607 |
} |
| 608 |
|
| 609 |
if (isset($dataArray['error'])) { |
| 610 |
fclose($file_handle); |
| 611 |
return $dataArray['error']; |
| 612 |
} |
| 613 |
|
| 614 |
if (isset($dataArray['from_url']) && |
| 615 |
($dataArray['from_url'] === 'from_url' || $dataArray['from_url'] === 'request')) { |
| 616 |
$dataRowsSeen++; |
| 617 |
continue; |
| 618 |
} |
| 619 |
|
| 620 |
try { |
| 621 |
$processedRows++; |
| 622 |
$wasOverwrite = false; |
| 623 |
if ($overwriteExisting && isset($dataArray['from_url']) && is_string($dataArray['from_url'])) { |
| 624 |
$existing = $this->redirectsRepository->getExistingRedirectForURL($dataArray['from_url']); |
| 625 |
$wasOverwrite = (is_array($existing) && isset($existing['id']) && (int)$existing['id'] !== 0); |
| 626 |
} |
| 627 |
// Surface the data-row line number so loadDataArrayFromFile() |
| 628 |
// can build "Invalid regex pattern at line N: ..." messages |
| 629 |
// that point the user at the row to fix. |
| 630 |
$dataArray['__line_number'] = $dataRowsSeen + 1; |
| 631 |
$issues = $this->loadDataArrayFromFile($dataArray, $dryRun, $overwriteExisting); |
| 632 |
if (count($issues) > 0) { |
| 633 |
$invalidRows++; |
| 634 |
} else { |
| 635 |
$validRows++; |
| 636 |
if ($wasOverwrite) { |
| 637 |
$overwrittenRows++; |
| 638 |
} |
| 639 |
} |
| 640 |
$anyIssuesToNote = array_merge($anyIssuesToNote, $issues); |
| 641 |
$dataRowsSeen++; |
| 642 |
} catch (\Throwable $e) { |
| 643 |
// Mid-import failure (PHP timeout exception, DB error, etc.). |
| 644 |
// Persist what we completed so the next call with the same |
| 645 |
// file content resumes from $dataRowsSeen (the failed row |
| 646 |
// gets retried; its from_url is the resume frontier). The |
| 647 |
// row we were processing did NOT succeed, so processedRows |
| 648 |
// is rolled back by 1 to keep counters truthful. |
| 649 |
if (!$dryRun) { |
| 650 |
$this->persistImportProgress($contentHash, array( |
| 651 |
'rows_processed' => $dataRowsSeen, |
| 652 |
'processed_count' => max(0, $processedRows - 1), |
| 653 |
'valid_count' => $validRows, |
| 654 |
'invalid_count' => $invalidRows, |
| 655 |
'overwritten_count' => $overwrittenRows, |
| 656 |
'issues' => $anyIssuesToNote, |
| 657 |
'last_error' => $e->getMessage(), |
| 658 |
'paused_at' => time(), |
| 659 |
)); |
| 660 |
} |
| 661 |
fclose($file_handle); |
| 662 |
$this->logger->warn(sprintf( |
| 663 |
'Import paused at row %d of %d due to: %s', |
| 664 |
$dataRowsSeen + 1, |
| 665 |
$dataRowsSeen + 1, |
| 666 |
$e->getMessage() |
| 667 |
)); |
| 668 |
return sprintf( |
| 669 |
__('Import paused at row %1$d. %2$d redirect(s) imported so far. Re-upload the same file to resume from row %1$d.', '404-solution'), |
| 670 |
$dataRowsSeen + 1, |
| 671 |
$validRows |
| 672 |
); |
| 673 |
} |
| 674 |
|
| 675 |
// Periodic checkpoint so a hard PHP timeout (uncatchable fatal) |
| 676 |
// still leaves a recent progress marker. Skipped during dry runs |
| 677 |
// since they perform no DB writes. |
| 678 |
if (!$dryRun && ($dataRowsSeen % self::IMPORT_PROGRESS_CHECKPOINT_INTERVAL) === 0) { |
| 679 |
$this->persistImportProgress($contentHash, array( |
| 680 |
'rows_processed' => $dataRowsSeen, |
| 681 |
'processed_count' => $processedRows, |
| 682 |
'valid_count' => $validRows, |
| 683 |
'invalid_count' => $invalidRows, |
| 684 |
'overwritten_count' => $overwrittenRows, |
| 685 |
'issues' => $anyIssuesToNote, |
| 686 |
)); |
| 687 |
} |
| 688 |
} |
| 689 |
fclose($file_handle); |
| 690 |
|
| 691 |
// Full traversal completed successfully: clear any prior progress |
| 692 |
// so a future unrelated upload of byte-identical content (e.g. |
| 693 |
// re-applying the same exports) starts fresh, not "resumes" from |
| 694 |
// the file's end. |
| 695 |
if (!$dryRun) { |
| 696 |
$this->clearImportProgress(); |
| 697 |
} |
| 698 |
|
| 699 |
if ($dryRun) { |
| 700 |
$msg = sprintf( |
| 701 |
__('Dry run complete. Valid redirects: %d. Invalid rows: %d. Total rows processed: %d.', '404-solution'), |
| 702 |
$validRows, |
| 703 |
$invalidRows, |
| 704 |
$processedRows |
| 705 |
); |
| 706 |
if (count($anyIssuesToNote) > 0) { |
| 707 |
$msg .= ' ' . __('Preview issues:', '404-solution') . ' ' . |
| 708 |
implode(", <BR/>\n", array_slice($anyIssuesToNote, 0, 20)); |
| 709 |
} |
| 710 |
return $msg; |
| 711 |
} |
| 712 |
|
| 713 |
if (count($anyIssuesToNote) > 0) { |
| 714 |
return __('Error:', '404-solution') . ' ' . implode(", <BR/>\n", $anyIssuesToNote); |
| 715 |
} |
| 716 |
|
| 717 |
if ($overwriteExisting && $overwrittenRows > 0) { |
| 718 |
return sprintf( |
| 719 |
__('The file seems to have loaded okay. %d existing redirect(s) were overwritten. Please check the redirects page.', '404-solution'), |
| 720 |
$overwrittenRows |
| 721 |
); |
| 722 |
} |
| 723 |
|
| 724 |
return __('The file seems to have loaded okay. Please check the redirects page.', '404-solution'); |
| 725 |
} |
| 726 |
|
| 727 |
/** |
| 728 |
* Type-narrowing helper for `mixed` values pulled out of the persisted |
| 729 |
* progress array. Keeps PHPStan level 9 happy without sprinkling |
| 730 |
* `is_int / is_numeric` guards through `doImportFile()`. |
| 731 |
* |
| 732 |
* @param array<string, mixed> $progress |
| 733 |
* @param string $key |
| 734 |
* @param int $default |
| 735 |
* @return int |
| 736 |
*/ |
| 737 |
private static function progressInt(array $progress, string $key, int $default): int { |
| 738 |
if (!isset($progress[$key])) { |
| 739 |
return $default; |
| 740 |
} |
| 741 |
$v = $progress[$key]; |
| 742 |
if (is_int($v)) { |
| 743 |
return $v; |
| 744 |
} |
| 745 |
if (is_numeric($v)) { |
| 746 |
return (int)$v; |
| 747 |
} |
| 748 |
return $default; |
| 749 |
} |
| 750 |
|
| 751 |
/** |
| 752 |
* Look up resumable-import progress for the supplied content hash. |
| 753 |
* Returns the persisted progress array only when the recorded hash |
| 754 |
* matches; mismatches (different file content) and absent records both |
| 755 |
* return null so the caller can begin a fresh import. |
| 756 |
* |
| 757 |
* @param string $contentHash sha256 of the current upload's contents. |
| 758 |
* @return array<string, mixed>|null |
| 759 |
*/ |
| 760 |
private function getResumeProgress($contentHash) { |
| 761 |
if ($contentHash === '' || !function_exists('get_option')) { |
| 762 |
return null; |
| 763 |
} |
| 764 |
$progress = get_option(self::IMPORT_PROGRESS_OPTION, null); |
| 765 |
if (!is_array($progress) || !isset($progress['hash']) || !is_string($progress['hash'])) { |
| 766 |
return null; |
| 767 |
} |
| 768 |
if ($progress['hash'] !== $contentHash) { |
| 769 |
return null; |
| 770 |
} |
| 771 |
/** @var array<string, mixed> $progress */ |
| 772 |
return $progress; |
| 773 |
} |
| 774 |
|
| 775 |
/** |
| 776 |
* Persist a resumable-import checkpoint keyed by the file's sha256 |
| 777 |
* hash. Writes are idempotent (latest wins) and small (counters + a |
| 778 |
* short issue list), so calling this every N rows is cheap. |
| 779 |
* |
| 780 |
* @param string $contentHash |
| 781 |
* @param array<string, mixed> $state |
| 782 |
* @return void |
| 783 |
*/ |
| 784 |
private function persistImportProgress($contentHash, $state) { |
| 785 |
if ($contentHash === '' || !function_exists('update_option')) { |
| 786 |
return; |
| 787 |
} |
| 788 |
$state['hash'] = $contentHash; |
| 789 |
update_option(self::IMPORT_PROGRESS_OPTION, $state); |
| 790 |
} |
| 791 |
|
| 792 |
/** |
| 793 |
* Clear the resume marker after a fully-successful import so a future |
| 794 |
* upload of byte-identical content starts fresh rather than "resuming" |
| 795 |
* from end-of-file. |
| 796 |
* |
| 797 |
* @return void |
| 798 |
*/ |
| 799 |
private function clearImportProgress() { |
| 800 |
if (function_exists('delete_option')) { |
| 801 |
delete_option(self::IMPORT_PROGRESS_OPTION); |
| 802 |
} |
| 803 |
} |
| 804 |
|
| 805 |
/** |
| 806 |
* @param array<string, mixed> $dataArray |
| 807 |
* @param bool $dryRun |
| 808 |
* @param bool $overwriteExisting When true, an existing redirect with the |
| 809 |
* same from_url is updated instead of being skipped. Default false |
| 810 |
* preserves historical safe-by-default behavior. |
| 811 |
* @return array<int, string> |
| 812 |
*/ |
| 813 |
function loadDataArrayFromFile($dataArray, $dryRun = false, $overwriteExisting = false) { |
| 814 |
$fromURL = isset($dataArray['from_url']) && is_string($dataArray['from_url']) ? $dataArray['from_url'] : ''; |
| 815 |
if ($fromURL === 'from_url' || $fromURL === 'request') { |
| 816 |
return array(); |
| 817 |
} |
| 818 |
|
| 819 |
// Explicit regex signal from the CSV takes priority over the narrow |
| 820 |
// URL-chars sniff further down. Recognized signals (any one wins): |
| 821 |
// 1. Native CSV `status` column literal 'Regex' (case-insensitive) |
| 822 |
// 2. Native CSV `status` numeric ABJ404_STATUS_REGEX value |
| 823 |
// 3. Redirection-plugin `regex` column '1' / 'true' / 'yes' |
| 824 |
$explicitRegex = $this->isExplicitRegexRow($dataArray); |
| 825 |
$status = $explicitRegex ? ABJ404_STATUS_REGEX : ABJ404_STATUS_MANUAL; |
| 826 |
$final_dest = isset($dataArray['to_url']) && is_string($dataArray['to_url']) ? $dataArray['to_url'] : ''; |
| 827 |
$anyIssuesToNote = array(); |
| 828 |
|
| 829 |
// Server-side regex auto-promote sniff. When the CSV row does not |
| 830 |
// carry an explicit regex signal but the from_url contains |
| 831 |
// unambiguous regex metachars (`* [ ] | ^ \ { }`), flip the |
| 832 |
// status to REGEX and apply the bare-`*` to `.*` glob fixup so the |
| 833 |
// stored pattern compiles at runtime. Applied regardless of |
| 834 |
// destination type because the canonical case (Troy's 55-row |
| 835 |
// import) imports `/sales/*` to internal pages, not just external |
| 836 |
// destinations as the legacy narrow sniff assumed. Done BEFORE |
| 837 |
// the existing-URL check so re-imports of the same CSV idempotently |
| 838 |
// resolve to the same canonical rewritten pattern. |
| 839 |
if (!$explicitRegex |
| 840 |
&& ABJ_404_Solution_RegexAutoPromote::looksLikeUnambiguousRegex($fromURL)) { |
| 841 |
$status = ABJ404_STATUS_REGEX; |
| 842 |
$glob = ABJ_404_Solution_RegexAutoPromote::applyGlobFixup($fromURL); |
| 843 |
$fromURL = $glob['url']; |
| 844 |
} |
| 845 |
|
| 846 |
// Validate at the boundary: if the row is explicitly flagged as a regex |
| 847 |
// redirect, refuse to persist a from_url that is not a syntactically |
| 848 |
// valid PHP pattern. Without this guard, the bad pattern reaches |
| 849 |
// SpellCheckerTrait_URLMatching::getPermalinkUsingRegEx() at runtime |
| 850 |
// and emits a PHP warning per 404 request. |
| 851 |
if ($explicitRegex) { |
| 852 |
$patternError = $this->validateRegexPattern($fromURL); |
| 853 |
if ($patternError !== '') { |
| 854 |
$lineNumber = isset($dataArray['__line_number']) && is_numeric($dataArray['__line_number']) |
| 855 |
? (int)$dataArray['__line_number'] : 0; |
| 856 |
$msg = $lineNumber > 0 |
| 857 |
? sprintf(__('Invalid regex pattern at line %d: %s (%s)', '404-solution'), |
| 858 |
$lineNumber, $fromURL, $patternError) |
| 859 |
: sprintf(__('Invalid regex pattern: %s (%s)', '404-solution'), |
| 860 |
$fromURL, $patternError); |
| 861 |
$this->logger->warn($msg); |
| 862 |
$anyIssuesToNote[] = $msg; |
| 863 |
return $anyIssuesToNote; |
| 864 |
} |
| 865 |
} |
| 866 |
|
| 867 |
$maybeExisting2 = $this->redirectsRepository->getExistingRedirectForURL($fromURL); |
| 868 |
$existingId = (count($maybeExisting2) > 0 && isset($maybeExisting2['id'])) ? (int)$maybeExisting2['id'] : 0; |
| 869 |
if ($existingId !== 0 && !$overwriteExisting) { |
| 870 |
$msg = __('Ignored importing redirect because a redirect with the same from URL already exists. URL:', '404-solution') . ' ' . $fromURL; |
| 871 |
$this->logger->warn($msg); |
| 872 |
$anyIssuesToNote[] = $msg; |
| 873 |
return $anyIssuesToNote; |
| 874 |
} |
| 875 |
|
| 876 |
$typePost = defined('ABJ404_TYPE_POST') ? constant('ABJ404_TYPE_POST') : 1; |
| 877 |
$typeCat = defined('ABJ404_TYPE_CAT') ? constant('ABJ404_TYPE_CAT') : 2; |
| 878 |
$typeTag = defined('ABJ404_TYPE_TAG') ? constant('ABJ404_TYPE_TAG') : 3; |
| 879 |
|
| 880 |
if (empty($final_dest)) { |
| 881 |
$type = ABJ404_TYPE_404_DISPLAYED; |
| 882 |
} else if ($final_dest == '5') { |
| 883 |
$type = ABJ404_TYPE_HOME; |
| 884 |
} else if (strpos($final_dest, 'http') !== false) { |
| 885 |
$type = ABJ404_TYPE_EXTERNAL; |
| 886 |
} else if (strpos($final_dest, '/') === 0) { |
| 887 |
$type = $typePost; |
| 888 |
} else { |
| 889 |
$msg = __('Unrecognized destination type while importing file. Destination:', '404-solution') . ' ' . $final_dest; |
| 890 |
$this->logger->warn($msg); |
| 891 |
$anyIssuesToNote[] = $msg; |
| 892 |
return $anyIssuesToNote; |
| 893 |
} |
| 894 |
|
| 895 |
if ($type == ABJ404_TYPE_404_DISPLAYED) { |
| 896 |
$final_dest = ABJ404_TYPE_404_DISPLAYED; |
| 897 |
} else if (strpos($final_dest, 'http') !== false) { |
| 898 |
$type = ABJ404_TYPE_EXTERNAL; |
| 899 |
} else if ($type == ABJ404_TYPE_HOME) { |
| 900 |
$final_dest = ABJ404_TYPE_HOME; |
| 901 |
} else { |
| 902 |
$slug = trim($final_dest, '/'); |
| 903 |
$postsFromSlugRows = $this->contentRepository->getPublishedPagesAndPostsIDs($slug); |
| 904 |
$postsFromCategoryRows = $this->contentRepository->getPublishedCategories(null, $slug); |
| 905 |
$postsFromTagRows = $this->contentRepository->getPublishedTags($slug); |
| 906 |
|
| 907 |
/** @var object{id?: int|string, term_id?: int|string}|null $postFromSlug */ |
| 908 |
$postFromSlug = isset($postsFromSlugRows[0]) ? $postsFromSlugRows[0] : null; |
| 909 |
/** @var object{term_id?: int|string}|null $postFromCategory */ |
| 910 |
$postFromCategory = isset($postsFromCategoryRows[0]) ? $postsFromCategoryRows[0] : null; |
| 911 |
/** @var object{term_id?: int|string}|null $postFromTag */ |
| 912 |
$postFromTag = isset($postsFromTagRows[0]) ? $postsFromTagRows[0] : null; |
| 913 |
|
| 914 |
if ($postFromSlug && isset($postFromSlug->id)) { |
| 915 |
$type = $typePost; |
| 916 |
$final_dest = (string)$postFromSlug->id; |
| 917 |
} else if ($postFromCategory && isset($postFromCategory->term_id)) { |
| 918 |
$type = $typeCat; |
| 919 |
$final_dest = (string)$postFromCategory->term_id; |
| 920 |
} else if ($postFromTag && isset($postFromTag->term_id)) { |
| 921 |
$type = $typeTag; |
| 922 |
$final_dest = (string)$postFromTag->term_id; |
| 923 |
} else { |
| 924 |
// Slug doesn't resolve to any post/category/tag (use EXTERNAL |
| 925 |
// so the path is used as-is by the redirect pipeline). Storing |
| 926 |
// a non-numeric final_dest with TYPE_POST would cause the |
| 927 |
// redirect to silently 404 (get_permalink() expects an ID). |
| 928 |
$type = ABJ404_TYPE_EXTERNAL; |
| 929 |
$this->logger->warn(__("Couldn't find post from slug. slug:", '404-solution') . ' ' . $slug); |
| 930 |
} |
| 931 |
} |
| 932 |
|
| 933 |
if (!$dryRun) { |
| 934 |
$engine = isset($dataArray['engine']) && is_string($dataArray['engine']) && $dataArray['engine'] !== '' |
| 935 |
? $dataArray['engine'] : 'import'; |
| 936 |
$code = isset($dataArray['code']) && is_numeric($dataArray['code']) |
| 937 |
? (string)(int)$dataArray['code'] : '301'; |
| 938 |
|
| 939 |
if ($existingId !== 0 && $overwriteExisting) { |
| 940 |
// Overwrite path: mutate the existing row so the user's bulk |
| 941 |
// CSV edit (e.g. Manual to Regex on 55 city patterns) lands |
| 942 |
// without per-row admin clicks. |
| 943 |
$this->redirectsRepository->updateRedirect((int)$type, (string)$final_dest, $fromURL, $existingId, $code, (int)$status); |
| 944 |
} else { |
| 945 |
$this->redirectsRepository->setupRedirect($fromURL, (string)$status, (string)$type, (string)$final_dest, $code, 0, $engine); |
| 946 |
} |
| 947 |
} |
| 948 |
|
| 949 |
return $anyIssuesToNote; |
| 950 |
} |
| 951 |
|
| 952 |
/** |
| 953 |
* Run the same pattern preparation SpellCheckerTrait_URLMatching uses |
| 954 |
* (forward-slashes escaped, then wrapped with `{` `}` or an alt delimiter) |
| 955 |
* and ask preg_match whether the result compiles. Returns the empty string |
| 956 |
* when the pattern is valid, or a short error message when it is not. |
| 957 |
* |
| 958 |
* Note: this validates the pattern shape only. It does not test against a |
| 959 |
* sample URL because preg_match returning 0 (no match) is still a "valid |
| 960 |
* pattern" outcome. |
| 961 |
* |
| 962 |
* @param string $fromUrl raw from_url from the CSV row |
| 963 |
* @return string '' when valid; a short error message otherwise |
| 964 |
*/ |
| 965 |
private function validateRegexPattern(string $fromUrl): string { |
| 966 |
if ($fromUrl === '') { |
| 967 |
return __('pattern is empty', '404-solution'); |
| 968 |
} |
| 969 |
|
| 970 |
// Mirror SpellCheckerTrait_URLMatching::getPreparedRegexPattern and |
| 971 |
// FunctionsPreg::regexMatch so we test exactly what runs at request |
| 972 |
// time. |
| 973 |
$prepared = str_replace('/', '\/', $fromUrl); |
| 974 |
$delimA = '{'; |
| 975 |
$delimB = '}'; |
| 976 |
if (strpos($prepared, '}') !== false) { |
| 977 |
// Mirror FunctionsPreg::findADelimiter for the alt-delimiter path. |
| 978 |
$candidates = array('`', '^', '|', '~', '!', ';', ':', ',', '@', "'", '/'); |
| 979 |
$picked = null; |
| 980 |
foreach ($candidates as $c) { |
| 981 |
if (strpos($prepared, $c) === false) { $picked = $c; break; } |
| 982 |
} |
| 983 |
if ($picked === null) { |
| 984 |
return __('cannot find a safe delimiter character', '404-solution'); |
| 985 |
} |
| 986 |
$delimA = $delimB = $picked; |
| 987 |
} |
| 988 |
|
| 989 |
$compiled = $delimA . $prepared . $delimB; |
| 990 |
$result = @preg_match($compiled, ''); |
| 991 |
if ($result === false) { |
| 992 |
$errMsg = function_exists('preg_last_error_msg') |
| 993 |
? preg_last_error_msg() |
| 994 |
: 'preg_match compilation failed'; |
| 995 |
return $errMsg; |
| 996 |
} |
| 997 |
return ''; |
| 998 |
} |
| 999 |
|
| 1000 |
/** |
| 1001 |
* Decide whether a parsed CSV row explicitly asks for STATUS_REGEX, based |
| 1002 |
* on the `status` column (native format) or `regex` column (Redirection |
| 1003 |
* format). Case-insensitive; tolerant of common truthy spellings. |
| 1004 |
* |
| 1005 |
* @param array<string, mixed> $dataArray |
| 1006 |
* @return bool |
| 1007 |
*/ |
| 1008 |
private function isExplicitRegexRow(array $dataArray): bool { |
| 1009 |
if (isset($dataArray['status']) && is_scalar($dataArray['status'])) { |
| 1010 |
$raw = strtolower(trim((string)$dataArray['status'])); |
| 1011 |
if ($raw === 'regex') { |
| 1012 |
return true; |
| 1013 |
} |
| 1014 |
if (is_numeric($raw) && (int)$raw === (int)ABJ404_STATUS_REGEX) { |
| 1015 |
return true; |
| 1016 |
} |
| 1017 |
} |
| 1018 |
if (isset($dataArray['regex']) && is_scalar($dataArray['regex'])) { |
| 1019 |
$raw = strtolower(trim((string)$dataArray['regex'])); |
| 1020 |
if ($raw === '1' || $raw === 'true' || $raw === 'yes') { |
| 1021 |
return true; |
| 1022 |
} |
| 1023 |
} |
| 1024 |
return false; |
| 1025 |
} |
| 1026 |
|
| 1027 |
/** |
| 1028 |
* @param mixed $line |
| 1029 |
* @return array<string, string> |
| 1030 |
*/ |
| 1031 |
function splitCsvLine($line) { |
| 1032 |
if (!is_string($line)) { |
| 1033 |
$line = is_scalar($line) ? (string)$line : ''; |
| 1034 |
} |
| 1035 |
|
| 1036 |
$data = array_map(function($v) { |
| 1037 |
return trim((string)$v); |
| 1038 |
}, str_getcsv($line, ',', '"', '\\')); |
| 1039 |
|
| 1040 |
if (count($data) === 5) { |
| 1041 |
return array( |
| 1042 |
'from_url' => $data[0], |
| 1043 |
'status' => $data[1], |
| 1044 |
'type' => $data[2], |
| 1045 |
'to_url' => $data[3], |
| 1046 |
'wp_type' => $data[4] |
| 1047 |
); |
| 1048 |
} else if (count($data) === 2) { |
| 1049 |
return array( |
| 1050 |
'from_url' => $data[0], |
| 1051 |
'to_url' => $data[1] |
| 1052 |
); |
| 1053 |
} |
| 1054 |
|
| 1055 |
return array('error' => sprintf(__('Invalid CSV format. %d columns found but 2 or 5 expected.', '404-solution'), count($data))); |
| 1056 |
} |
| 1057 |
|
| 1058 |
/** |
| 1059 |
* @param array<int, string> $columns |
| 1060 |
* @return bool |
| 1061 |
*/ |
| 1062 |
function isCompatibleImportHeaderRow($columns) { |
| 1063 |
$normalized = $this->normalizeImportHeaders($columns); |
| 1064 |
$fromIndex = $this->findImportHeaderIndex($normalized, array('from_url', 'request', 'source', 'url', 'match_url')); |
| 1065 |
$toIndex = $this->findImportHeaderIndex($normalized, array('to_url', 'target', 'destination', 'action_data', 'redirect_to', 'url_to')); |
| 1066 |
return ($fromIndex !== -1 && $toIndex !== -1); |
| 1067 |
} |
| 1068 |
|
| 1069 |
/** |
| 1070 |
* @param array<int, string> $columns |
| 1071 |
* @return array<int, string|null> |
| 1072 |
*/ |
| 1073 |
function normalizeImportHeaders($columns) { |
| 1074 |
return array_map(function($value) { |
| 1075 |
$value = preg_replace('/^\xEF\xBB\xBF/', '', (string)$value); |
| 1076 |
$value = trim(strtolower((string)$value)); |
| 1077 |
return preg_replace('/[^a-z0-9_]/', '', str_replace(' ', '_', $value)); |
| 1078 |
}, $columns); |
| 1079 |
} |
| 1080 |
|
| 1081 |
/** |
| 1082 |
* Best-effort format detection for import UX and diagnostics. |
| 1083 |
* |
| 1084 |
* @param array<int, string> $columns Raw header row. |
| 1085 |
* @return string One of: native, redirection, safe_redirect_manager, simple_301, unknown. |
| 1086 |
*/ |
| 1087 |
function detectImportFormatFromHeaders($columns) { |
| 1088 |
$normalized = $this->normalizeImportHeaders($columns); |
| 1089 |
|
| 1090 |
if (in_array('source', $normalized, true) && |
| 1091 |
in_array('target', $normalized, true) && |
| 1092 |
in_array('regex', $normalized, true)) { |
| 1093 |
return 'redirection'; |
| 1094 |
} |
| 1095 |
|
| 1096 |
if (in_array('redirect_from', $normalized, true) && |
| 1097 |
in_array('redirect_to', $normalized, true)) { |
| 1098 |
return 'safe_redirect_manager'; |
| 1099 |
} |
| 1100 |
|
| 1101 |
if (in_array('request', $normalized, true) && |
| 1102 |
in_array('destination', $normalized, true)) { |
| 1103 |
return 'simple_301'; |
| 1104 |
} |
| 1105 |
|
| 1106 |
if ((in_array('from_url', $normalized, true) && |
| 1107 |
in_array('to_url', $normalized, true)) || |
| 1108 |
(in_array('from_url', $normalized, true) && |
| 1109 |
in_array('status', $normalized, true) && |
| 1110 |
in_array('type', $normalized, true) && |
| 1111 |
in_array('to_url', $normalized, true))) { |
| 1112 |
return 'native'; |
| 1113 |
} |
| 1114 |
|
| 1115 |
return 'unknown'; |
| 1116 |
} |
| 1117 |
|
| 1118 |
/** |
| 1119 |
* @param array<int, string> $row |
| 1120 |
* @param array<int, string|null> $normalizedHeaders |
| 1121 |
* @return array<string, string> |
| 1122 |
*/ |
| 1123 |
function mapImportRowByHeaders($row, $normalizedHeaders) { |
| 1124 |
$fromIndex = $this->findImportHeaderIndex($normalizedHeaders, array('from_url', 'request', 'source', 'url', 'match_url')); |
| 1125 |
$toIndex = $this->findImportHeaderIndex($normalizedHeaders, array('to_url', 'target', 'destination', 'action_data', 'redirect_to', 'url_to')); |
| 1126 |
|
| 1127 |
if ($fromIndex === -1 || $toIndex === -1) { |
| 1128 |
return array('error' => __('Invalid CSV format. Could not map source/destination columns.', '404-solution')); |
| 1129 |
} |
| 1130 |
|
| 1131 |
$from = array_key_exists($fromIndex, $row) ? trim((string)$row[$fromIndex]) : ''; |
| 1132 |
$to = array_key_exists($toIndex, $row) ? trim((string)$row[$toIndex]) : ''; |
| 1133 |
|
| 1134 |
if ($from === '' && $to === '') { |
| 1135 |
return array('from_url' => '', 'to_url' => ''); |
| 1136 |
} |
| 1137 |
|
| 1138 |
$result = array( |
| 1139 |
'from_url' => $from, |
| 1140 |
'to_url' => $to, |
| 1141 |
); |
| 1142 |
|
| 1143 |
$engineIndex = $this->findImportHeaderIndex($normalizedHeaders, array('engine')); |
| 1144 |
if ($engineIndex !== -1 && array_key_exists($engineIndex, $row)) { |
| 1145 |
$result['engine'] = trim((string)$row[$engineIndex]); |
| 1146 |
} |
| 1147 |
|
| 1148 |
$codeIndex = $this->findImportHeaderIndex($normalizedHeaders, array('code', 'redirect_code', 'http_code')); |
| 1149 |
if ($codeIndex !== -1 && array_key_exists($codeIndex, $row)) { |
| 1150 |
$result['code'] = trim((string)$row[$codeIndex]); |
| 1151 |
} |
| 1152 |
|
| 1153 |
// Native CSV: textual status (Manual / Regex / Auto / Captured / Ignored / Later). |
| 1154 |
$statusIndex = $this->findImportHeaderIndex($normalizedHeaders, array('status', 'redirect_status')); |
| 1155 |
if ($statusIndex !== -1 && array_key_exists($statusIndex, $row)) { |
| 1156 |
$result['status'] = trim((string)$row[$statusIndex]); |
| 1157 |
} |
| 1158 |
|
| 1159 |
// Redirection-plugin CSV: explicit `regex` flag column (0/1). |
| 1160 |
$regexIndex = $this->findImportHeaderIndex($normalizedHeaders, array('regex', 'is_regex')); |
| 1161 |
if ($regexIndex !== -1 && array_key_exists($regexIndex, $row)) { |
| 1162 |
$result['regex'] = trim((string)$row[$regexIndex]); |
| 1163 |
} |
| 1164 |
|
| 1165 |
return $result; |
| 1166 |
} |
| 1167 |
|
| 1168 |
/** |
| 1169 |
* @param array<int, string|null> $headers |
| 1170 |
* @param array<int, string> $candidates |
| 1171 |
* @return int |
| 1172 |
*/ |
| 1173 |
private function findImportHeaderIndex($headers, $candidates) { |
| 1174 |
foreach ($candidates as $candidate) { |
| 1175 |
$idx = array_search($candidate, $headers, true); |
| 1176 |
if ($idx !== false) { |
| 1177 |
return (int)$idx; |
| 1178 |
} |
| 1179 |
} |
| 1180 |
return -1; |
| 1181 |
} |
| 1182 |
|
| 1183 |
/** |
| 1184 |
* @param array<int, string> $columns Already parsed CSV columns for one row. |
| 1185 |
* @return array<string, string> |
| 1186 |
*/ |
| 1187 |
function mapImportRowWithoutHeaders($columns) { |
| 1188 |
$columns = array_values($columns); |
| 1189 |
if (count($columns) === 7) { |
| 1190 |
return array( |
| 1191 |
'from_url' => trim((string)$columns[0]), |
| 1192 |
'status' => trim((string)$columns[1]), |
| 1193 |
'type' => trim((string)$columns[2]), |
| 1194 |
'to_url' => trim((string)$columns[3]), |
| 1195 |
'wp_type' => trim((string)$columns[4]), |
| 1196 |
'engine' => trim((string)$columns[5]), |
| 1197 |
'code' => trim((string)$columns[6]), |
| 1198 |
); |
| 1199 |
} |
| 1200 |
if (count($columns) === 6) { |
| 1201 |
return array( |
| 1202 |
'from_url' => trim((string)$columns[0]), |
| 1203 |
'status' => trim((string)$columns[1]), |
| 1204 |
'type' => trim((string)$columns[2]), |
| 1205 |
'to_url' => trim((string)$columns[3]), |
| 1206 |
'wp_type' => trim((string)$columns[4]), |
| 1207 |
'engine' => trim((string)$columns[5]), |
| 1208 |
); |
| 1209 |
} |
| 1210 |
if (count($columns) === 5) { |
| 1211 |
return array( |
| 1212 |
'from_url' => trim((string)$columns[0]), |
| 1213 |
'status' => trim((string)$columns[1]), |
| 1214 |
'type' => trim((string)$columns[2]), |
| 1215 |
'to_url' => trim((string)$columns[3]), |
| 1216 |
'wp_type' => trim((string)$columns[4]), |
| 1217 |
); |
| 1218 |
} |
| 1219 |
if (count($columns) === 2) { |
| 1220 |
return array( |
| 1221 |
'from_url' => trim((string)$columns[0]), |
| 1222 |
'to_url' => trim((string)$columns[1]), |
| 1223 |
); |
| 1224 |
} |
| 1225 |
return array('error' => sprintf(__('Invalid CSV format. %d columns found but 2, 5, 6, or 7 expected.', '404-solution'), count($columns))); |
| 1226 |
} |
| 1227 |
|
| 1228 |
/** |
| 1229 |
* Detect delimiter by inspecting the first non-empty line. |
| 1230 |
* |
| 1231 |
* @param resource $fileHandle |
| 1232 |
* @return string |
| 1233 |
*/ |
| 1234 |
function detectCsvDelimiterFromFile($fileHandle) { |
| 1235 |
while (($line = fgets($fileHandle)) !== false) { |
| 1236 |
if (trim($line) === '') { |
| 1237 |
continue; |
| 1238 |
} |
| 1239 |
$comma = count(str_getcsv($line, ',', '"', '\\')); |
| 1240 |
$semicolon = count(str_getcsv($line, ';', '"', '\\')); |
| 1241 |
$tab = count(str_getcsv($line, "\t", '"', '\\')); |
| 1242 |
|
| 1243 |
if ($semicolon > $comma && $semicolon >= $tab) { |
| 1244 |
return ';'; |
| 1245 |
} |
| 1246 |
if ($tab > $comma && $tab > $semicolon) { |
| 1247 |
return "\t"; |
| 1248 |
} |
| 1249 |
return ','; |
| 1250 |
} |
| 1251 |
return ','; |
| 1252 |
} |
| 1253 |
} |
| 1254 |
|