PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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 / ImportExportService.php

ImportExportService.php in 404 Solution 4.1.19, at includes/ImportExportService.php

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