| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Specialized, non-paginated reads of the redirects table for callers that |
| 9 |
* don't go through the staged admin-list pipeline. |
| 10 |
* |
| 11 |
* Owns: |
| 12 |
* - doRedirectsExport: stream the redirects table to a CSV temp file |
| 13 |
* - getRedirectsWithRegEx: regex redirects with a static request-scoped cache |
| 14 |
* - getManualRedirectsWithRegexMetachars: manual redirects whose URL |
| 15 |
* contains regex metacharacters (for the matcher's wildcard fallback) |
| 16 |
* - getExtraDataToPermalinkSuggestions: post metadata for suggestion ids |
| 17 |
* |
| 18 |
* Extracted from ViewReadService in the i805 decomposition. The regex |
| 19 |
* cache is still owned by RedirectsRepository (its static cache holds the |
| 20 |
* per-request state); this reader just consults it. |
| 21 |
*/ |
| 22 |
class ABJ_404_Solution_RedirectsBulkReader { |
| 23 |
|
| 24 |
/** Number of rows fetched by each keyset page once caching is unsafe. */ |
| 25 |
const REGEX_READ_BATCH_SIZE = 250; |
| 26 |
|
| 27 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 28 |
private $dbCore; |
| 29 |
|
| 30 |
/** @var ABJ_404_Solution_ViewQueryBuilder */ |
| 31 |
private $queryBuilder; |
| 32 |
|
| 33 |
/** @var ABJ_404_Solution_Functions */ |
| 34 |
private $f; |
| 35 |
|
| 36 |
/** |
| 37 |
* @param ABJ_404_Solution_DatabaseCore $dbCore |
| 38 |
* @param ABJ_404_Solution_ViewQueryBuilder $queryBuilder |
| 39 |
* @param ABJ_404_Solution_Functions $f |
| 40 |
*/ |
| 41 |
public function __construct( |
| 42 |
ABJ_404_Solution_DatabaseCore $dbCore, |
| 43 |
ABJ_404_Solution_ViewQueryBuilder $queryBuilder, |
| 44 |
$f |
| 45 |
) { |
| 46 |
$this->dbCore = $dbCore; |
| 47 |
$this->queryBuilder = $queryBuilder; |
| 48 |
$this->f = $f; |
| 49 |
} |
| 50 |
|
| 51 |
/** |
| 52 |
* Stream the export query straight to a CSV temp file via mysqli to keep |
| 53 |
* the row buffer bounded on large redirect tables. |
| 54 |
* |
| 55 |
* @param string $tempFile |
| 56 |
* @return void |
| 57 |
*/ |
| 58 |
public function doRedirectsExport(string $tempFile): void { |
| 59 |
global $wpdb; |
| 60 |
|
| 61 |
if (file_exists($tempFile)) { |
| 62 |
ABJ_404_Solution_FileSystemService::safeUnlink($tempFile); |
| 63 |
} |
| 64 |
|
| 65 |
$query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getRedirectsExport.sql"); |
| 66 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 67 |
|
| 68 |
$result = mysqli_query($wpdb->dbh, $query); |
| 69 |
if ($result instanceof \mysqli_result) { |
| 70 |
$fh = fopen($tempFile, 'w'); |
| 71 |
if ($fh === false) { |
| 72 |
mysqli_free_result($result); |
| 73 |
return; |
| 74 |
} |
| 75 |
// try/finally: mysqli's default error mode (MYSQLI_REPORT_ERROR | |
| 76 |
// MYSQLI_REPORT_STRICT since PHP 8.1) throws mysqli_sql_exception |
| 77 |
// on a dropped connection mid-fetch. Without a guaranteed close |
| 78 |
// here, that exception would skip fclose($fh) and leak the file |
| 79 |
// handle. Same resource-lifecycle shape as |
| 80 |
// includes/import/ImportService.php::doImportFile(). |
| 81 |
try { |
| 82 |
fputcsv($fh, array('from_url', 'status', 'type', 'to_url', 'wp_type', 'engine', 'code'), ',', '"', '\\'); |
| 83 |
|
| 84 |
while (($row = mysqli_fetch_array($result, MYSQLI_ASSOC))) { |
| 85 |
fputcsv($fh, array( |
| 86 |
$row['from_url'], |
| 87 |
$row['status'], |
| 88 |
$row['type'], |
| 89 |
$row['to_url'], |
| 90 |
$row['type_wp'], |
| 91 |
isset($row['engine']) ? $row['engine'] : '', |
| 92 |
isset($row['code']) ? $row['code'] : '301' |
| 93 |
), ',', '"', '\\'); |
| 94 |
} |
| 95 |
} finally { |
| 96 |
fclose($fh); |
| 97 |
mysqli_free_result($result); |
| 98 |
} |
| 99 |
} |
| 100 |
} |
| 101 |
|
| 102 |
/** @return iterable<int, array<string, mixed>> */ |
| 103 |
public function getRedirectsWithRegEx(): iterable { |
| 104 |
$cached = ABJ_404_Solution_RedirectsRepository::getRegexRedirectsCache(); |
| 105 |
$disabled = ABJ_404_Solution_RedirectsRepository::isRegexCacheDisabled(); |
| 106 |
|
| 107 |
if ($cached !== null && !$disabled) { |
| 108 |
return $cached; |
| 109 |
} |
| 110 |
|
| 111 |
if ($disabled) { |
| 112 |
return $this->iterateAllRegexRedirectsInBatches(); |
| 113 |
} |
| 114 |
|
| 115 |
$results = $this->queryBuilder->queryRegexRedirects(ABJ_404_Solution_RedirectsRepository::REGEX_CACHE_MAX_COUNT + 1); |
| 116 |
|
| 117 |
if (count($results) <= ABJ_404_Solution_RedirectsRepository::REGEX_CACHE_MAX_COUNT) { |
| 118 |
ABJ_404_Solution_RedirectsRepository::setRegexRedirectsCache($results); |
| 119 |
} else { |
| 120 |
ABJ_404_Solution_RedirectsRepository::setRegexCacheDisabled(true); |
| 121 |
return $this->iterateAllRegexRedirectsInBatches($results); |
| 122 |
} |
| 123 |
|
| 124 |
return $results; |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Stream a complete regex redirect read without allowing either a SQL |
| 129 |
* result set or the PHP row collection to grow without bound. The optional |
| 130 |
* leading rows are the cache-threshold probe and are reused so the common |
| 131 |
* 51+ path does not reread them. |
| 132 |
* |
| 133 |
* @param array<int, array<string, mixed>> $leadingRows |
| 134 |
* @return iterable<int, array<string, mixed>> |
| 135 |
*/ |
| 136 |
private function iterateAllRegexRedirectsInBatches(array $leadingRows = array()): iterable { |
| 137 |
// DESIGN-AUDIT-OK(2026-08-21, owner): A total-work cap would silently make later valid regex rules unreachable, reproducing Troy's 51-rule defect. |
| 138 |
// The admin-owned finite table is streamed in 250-row pages, holds bounded memory, and stops as soon as a caller finds a match. |
| 139 |
$afterId = $this->greatestRedirectId($leadingRows); |
| 140 |
foreach ($leadingRows as $row) { |
| 141 |
yield $row; |
| 142 |
} |
| 143 |
|
| 144 |
do { |
| 145 |
$page = $this->queryBuilder->queryRegexRedirectsPage(array( |
| 146 |
'after_id' => $afterId, |
| 147 |
'limit' => self::REGEX_READ_BATCH_SIZE, |
| 148 |
)); |
| 149 |
if (empty($page)) { |
| 150 |
break; |
| 151 |
} |
| 152 |
|
| 153 |
$nextAfterId = $this->greatestRedirectId($page); |
| 154 |
if ($nextAfterId <= $afterId) { |
| 155 |
throw new UnexpectedValueException( |
| 156 |
'Regex redirect keyset page did not advance past id ' . $afterId . '.' |
| 157 |
); |
| 158 |
} |
| 159 |
|
| 160 |
foreach ($page as $row) { |
| 161 |
yield $row; |
| 162 |
} |
| 163 |
$afterId = $nextAfterId; |
| 164 |
} while (count($page) === self::REGEX_READ_BATCH_SIZE); |
| 165 |
} |
| 166 |
|
| 167 |
/** |
| 168 |
* @param array<int, array<string, mixed>> $rows |
| 169 |
*/ |
| 170 |
private function greatestRedirectId(array $rows): int { |
| 171 |
$greatestId = 0; |
| 172 |
foreach ($rows as $row) { |
| 173 |
$id = isset($row['id']) && is_scalar($row['id']) ? (int)$row['id'] : 0; |
| 174 |
$greatestId = max($greatestId, $id); |
| 175 |
} |
| 176 |
return $greatestId; |
| 177 |
} |
| 178 |
|
| 179 |
/** @return array<int, array<string, mixed>> */ |
| 180 |
// DESIGN-AUDIT-OK(2026-06-19, owner): status=MANUAL AND disabled=0 seeks via |
| 181 |
// idx_status_disabled to the few hand-made MANUAL rows first, so INSTR() runs only |
| 182 |
// over that bounded subset, not a full table scan. A LIMIT would drop valid manual |
| 183 |
// regex redirects (changes results), so no cap. Reviewed + accepted 2026-06-18. |
| 184 |
public function getManualRedirectsWithRegexMetachars(): array { |
| 185 |
$query = "select \n {wp_abj404_redirects}.id,\n {wp_abj404_redirects}.url,\n {wp_abj404_redirects}.status,\n" |
| 186 |
. " {wp_abj404_redirects}.type,\n {wp_abj404_redirects}.final_dest,\n {wp_abj404_redirects}.code,\n" |
| 187 |
. " {wp_abj404_redirects}.timestamp,\n {wp_posts}.id as wp_post_id\n "; |
| 188 |
$query .= "from {wp_abj404_redirects}\n " . |
| 189 |
" LEFT OUTER JOIN {wp_posts} \n " . |
| 190 |
" on {wp_abj404_redirects}.final_dest = {wp_posts}.id \n "; |
| 191 |
|
| 192 |
$query .= "where status = " . ABJ404_STATUS_MANUAL . " \n " . |
| 193 |
" and disabled = 0 \n " . |
| 194 |
" and (INSTR(`url`, '*') > 0 " . |
| 195 |
" OR INSTR(`url`, '[') > 0 " . |
| 196 |
" OR INSTR(`url`, ']') > 0 " . |
| 197 |
" OR INSTR(`url`, '|') > 0 " . |
| 198 |
" OR INSTR(`url`, '^') > 0 " . |
| 199 |
" OR INSTR(`url`, '\\\\') > 0 " . |
| 200 |
" OR INSTR(`url`, '{') > 0 " . |
| 201 |
" OR INSTR(`url`, '}') > 0)"; |
| 202 |
$results = $this->dbCore->queryAndGetResults($query); |
| 203 |
|
| 204 |
/** @var array<int, array<string, mixed>> $rows */ |
| 205 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 206 |
return $rows; |
| 207 |
} |
| 208 |
|
| 209 |
/** |
| 210 |
* @param array<int, string> $postIDs |
| 211 |
* @return array<int, mixed> |
| 212 |
*/ |
| 213 |
public function getExtraDataToPermalinkSuggestions(array $postIDs): array { |
| 214 |
$postIDs = array_map('absint', $postIDs); |
| 215 |
$postIDJoined = implode(', ', $postIDs); |
| 216 |
|
| 217 |
$query = ABJ_404_Solution_FileSystemService::readFileContents(__DIR__ . "/../sql/getAdditionalPostData.sql"); |
| 218 |
$query = $this->f->str_replace('{IDS_TO_INCLUDE}', $postIDJoined, $query); |
| 219 |
$query = $this->dbCore->doTableNameReplacements($query); |
| 220 |
$query = $this->f->doNormalReplacements($query); |
| 221 |
|
| 222 |
$results = $this->dbCore->queryAndGetResults($query); |
| 223 |
|
| 224 |
/** @var array<int, mixed> $rows */ |
| 225 |
$rows = is_array($results['rows']) ? $results['rows'] : array(); |
| 226 |
return $rows; |
| 227 |
} |
| 228 |
} |
| 229 |
|