PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / import / ForeignRedirectSourceReader.php

ForeignRedirectSourceReader.php in 404 Solution trunk, at includes/import/ForeignRedirectSourceReader.php

498 lines 19.0 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 * Reads redirect rows out of other redirect plugins' storage.
9 *
10 * This is the data-access half of the cross-plugin import feature: it detects
11 * which source plugins are installed (by probing for their tables or custom
12 * post type) and reads each source's rows into one common normalized shape
13 * (`['source_url' => string, 'dest_url' => string, 'code' => int, 'is_regex' => bool]`),
14 * or counts them without materializing rows. It makes no decision about how
15 * those rows become 404 Solution redirects; that business logic lives in
16 * {@see ABJ_404_Solution_CrossPluginImporter}. Actual query execution and
17 * driver-error interpretation is delegated to
18 * {@see ABJ_404_Solution_ForeignSourceQueryGateway}; this class owns only
19 * the per-source schema knowledge (table/CPT names, column names, WHERE
20 * filters) for each of the five supported sources.
21 *
22 * Supported sources:
23 * - Rank Math (rank_math_redirections)
24 * - Yoast SEO Premium (yoast_seo_redirects)
25 * - AIOSEO (aioseo_redirects)
26 * - Safe Redirect Manager (redirect_rule CPT)
27 * - Redirection plugin (redirection_items)
28 */
29 class ABJ_404_Solution_ForeignRedirectSourceReader {
30
31 /**
32 * Row/post count per page for every paginated foreign-source read (M502,
33 * 2026-07-14): `LIMIT`/`OFFSET` for table-backed readers, `posts_per_page`
34 * for the CPT-backed Safe Redirect Manager reader. 500 keeps a page's PHP
35 * array well within shared-hosting memory limits even at VARCHAR(2048)
36 * source/dest width, while still finishing typical sites in 1-2 pages.
37 */
38 public const IMPORT_PAGE_SIZE = 500;
39
40 /** @var ABJ_404_Solution_Logging */
41 private $logger;
42
43 /** @var ABJ_404_Solution_ForeignSourceQueryGateway */
44 private $queryGateway;
45
46 /**
47 * @param mixed $redirectsRepository Used only to resolve a database query
48 * service when one is not supplied directly.
49 * @param ABJ_404_Solution_Logging $logger
50 * @param ABJ_404_Solution_DatabaseQueryInterface|null $dbQuery
51 */
52 public function __construct($redirectsRepository, $logger, $dbQuery = null) {
53 $this->logger = $logger;
54 $this->queryGateway = new ABJ_404_Solution_ForeignSourceQueryGateway($redirectsRepository, $logger, $dbQuery);
55 }
56
57 /**
58 * Detect which source plugins are installed by checking for their DB tables
59 * (or, for Safe Redirect Manager, by checking for the CPT).
60 *
61 * @return array<string, bool> e.g. ['rankmath' => true, 'redirection' => false, ...]
62 */
63 public function detectInstalledPlugins(): array {
64 global $wpdb;
65
66 if (!$wpdb) {
67 return array('rankmath' => false, 'yoast' => false, 'aioseo' => false,
68 'redirection' => false, 'safe-redirect-manager' => false);
69 }
70
71 $tableMap = array(
72 'rankmath' => $wpdb->prefix . 'rank_math_redirections',
73 'yoast' => $wpdb->prefix . 'yoast_seo_redirects',
74 'aioseo' => $wpdb->prefix . 'aioseo_redirects',
75 'redirection' => $wpdb->prefix . 'redirection_items',
76 );
77
78 $detected = array();
79 foreach ($tableMap as $slug => $tableName) {
80 $detected[$slug] = $this->queryGateway->tableExists($tableName);
81 }
82
83 // Safe Redirect Manager uses a custom post type, not a dedicated table.
84 // Detect it via the CPT registration rather than a table check.
85 $detected['safe-redirect-manager'] = function_exists('post_type_exists') && post_type_exists('redirect_rule');
86
87 return $detected;
88 }
89
90 /**
91 * Read and normalize redirect rows from the given source plugin as a
92 * generator that yields one normalized row at a time.
93 *
94 * Each per-source reader pages in IMPORT_PAGE_SIZE-row chunks (table-
95 * backed: `LIMIT`/`OFFSET`; CPT-backed Safe Redirect Manager: paged
96 * get_posts()) rather than one unbounded read (M502, 2026-07-14: a
97 * source table with tens of thousands of rows previously materialized
98 * the entire result set in PHP memory before a row was written). The
99 * caller ({@see ABJ_404_Solution_CrossPluginImporter::importFrom()})
100 * consumes this row-by-row, so at most one page is ever held in memory.
101 *
102 * @param string $source One of 'rankmath', 'yoast', 'aioseo',
103 * 'safe-redirect-manager', 'redirection'
104 * @return \Generator<int, array<string, mixed>>
105 */
106 public function readSource(string $source): \Generator {
107 switch ($source) {
108 case 'rankmath':
109 yield from $this->readRankMath();
110 return;
111 case 'yoast':
112 yield from $this->readYoast();
113 return;
114 case 'aioseo':
115 yield from $this->readAIOSEO();
116 return;
117 case 'safe-redirect-manager':
118 yield from $this->readSafeRedirectManager();
119 return;
120 case 'redirection':
121 yield from $this->readRedirection();
122 return;
123 default:
124 $this->logger->debugMessage(
125 'CrossPluginImporter: unknown source "' . $source . '". Returning empty.'
126 );
127 return;
128 }
129 }
130
131 /**
132 * Count redirect rows available from the given source plugin without
133 * materializing the full row set (M502, 2026-07-14: the AJAX preview
134 * handler previously called readSource() -- which fully reads every row
135 * from the source plugin's storage -- solely to count(), risking memory
136 * or time exhaustion on a large source history).
137 *
138 * The four sources backed by a dedicated DB table get a real
139 * `SELECT COUNT(*)` mirroring the WHERE clause of the matching
140 * read*() method above. Safe Redirect Manager is a custom post type,
141 * not a table, so it is counted with wp_count_posts() -- a single
142 * grouped-by-status COUNT query WordPress core already provides,
143 * not a per-row get_posts() fetch. All five sources therefore get a
144 * genuine count-only path; none require unserializing a full options
145 * blob (this plugin's cross-plugin sources are table/CPT-backed only).
146 *
147 * @param string $source One of 'rankmath', 'yoast', 'aioseo',
148 * 'safe-redirect-manager', 'redirection'
149 * @return int
150 */
151 public function countSource(string $source): int {
152 global $wpdb;
153
154 switch ($source) {
155 case 'rankmath':
156 return $this->countTableRows($wpdb->prefix . 'rank_math_redirections', "status = 'active'");
157 case 'yoast':
158 return $this->countTableRows($wpdb->prefix . 'yoast_seo_redirects', '');
159 case 'aioseo':
160 return $this->countTableRows($wpdb->prefix . 'aioseo_redirects', "status = 'active'");
161 case 'redirection':
162 return $this->countTableRows($wpdb->prefix . 'redirection_items', "status = 'enabled'");
163 case 'safe-redirect-manager':
164 return $this->countSafeRedirectManager();
165 default:
166 $this->logger->debugMessage(
167 'CrossPluginImporter: unknown source "' . $source . '" for count. Returning 0.'
168 );
169 return 0;
170 }
171 }
172
173 /**
174 * Page through a table-backed SELECT in IMPORT_PAGE_SIZE-row chunks,
175 * appending `ORDER BY \`id\` ASC LIMIT <n> OFFSET <n>` to $baseSql and
176 * re-issuing the query until a page returns fewer than IMPORT_PAGE_SIZE
177 * rows. Centralizes the pagination loop so every table-backed reader
178 * below shares one implementation (M502: previously each reader issued
179 * its own single unbounded SELECT with no LIMIT).
180 *
181 * Every source table this class reads from (rank_math_redirections,
182 * yoast_seo_redirects, aioseo_redirects, redirection_items) has an
183 * auto-increment `id` primary key, so ORDER BY id ASC gives stable,
184 * gap-free paging.
185 *
186 * IMPORT_PAGE_SIZE and $offset are internally-generated integers (never
187 * derived from request input), so inlining them into the SQL string is
188 * safe; {@see ABJ_404_Solution_ForeignSourceQueryGateway::queryRows()}
189 * takes a plain SQL string with no placeholder/param support.
190 *
191 * @param string $baseSql SELECT ... FROM ... [WHERE ...], without ORDER BY/LIMIT/OFFSET
192 * @return \Generator<int, array<string, mixed>> Raw (un-normalized) rows
193 */
194 private function pageTableQuery(string $baseSql): \Generator {
195 $offset = 0;
196 while (true) {
197 $sql = $baseSql . ' ORDER BY `id` ASC LIMIT ' . (int)self::IMPORT_PAGE_SIZE . ' OFFSET ' . (int)$offset;
198 $rows = $this->queryGateway->queryRows($sql);
199
200 if (empty($rows)) {
201 return;
202 }
203
204 foreach ($rows as $row) {
205 yield $row;
206 }
207
208 if (count($rows) < self::IMPORT_PAGE_SIZE) {
209 return;
210 }
211 $offset += self::IMPORT_PAGE_SIZE;
212 }
213 }
214
215 /**
216 * Read Rank Math redirections from rank_math_redirections table.
217 *
218 * @return \Generator<int, array<string, mixed>>
219 */
220 private function readRankMath(): \Generator {
221 global $wpdb;
222
223 $tableName = $wpdb->prefix . 'rank_math_redirections';
224 if (!$this->queryGateway->tableExists($tableName)) {
225 return;
226 }
227
228 foreach ($this->pageTableQuery(
229 "SELECT source_url, dest_url, redirect_type, regex_flag
230 FROM `{$tableName}`
231 WHERE status = 'active'"
232 ) as $row) {
233 if (!is_array($row)) {
234 continue;
235 }
236 $sourceUrl = isset($row['source_url']) && is_string($row['source_url']) ? trim($row['source_url']) : '';
237 $destUrl = isset($row['dest_url']) && is_string($row['dest_url']) ? trim($row['dest_url']) : '';
238 $code = isset($row['redirect_type']) && is_numeric($row['redirect_type'])
239 ? (int)$row['redirect_type']
240 : 301;
241 $isRegex = !empty($row['regex_flag']) && $row['regex_flag'] != '0';
242
243 if ($sourceUrl === '' || $destUrl === '') {
244 continue;
245 }
246 yield array(
247 'source_url' => $sourceUrl,
248 'dest_url' => $destUrl,
249 'code' => $code,
250 'is_regex' => $isRegex,
251 );
252 }
253 }
254
255 /**
256 * Read Yoast SEO Premium redirects from yoast_seo_redirects table.
257 *
258 * @return \Generator<int, array<string, mixed>>
259 */
260 private function readYoast(): \Generator {
261 global $wpdb;
262
263 $tableName = $wpdb->prefix . 'yoast_seo_redirects';
264 if (!$this->queryGateway->tableExists($tableName)) {
265 return;
266 }
267
268 foreach ($this->pageTableQuery(
269 "SELECT origin, target, redirect_type
270 FROM `{$tableName}`"
271 ) as $row) {
272 if (!is_array($row)) {
273 continue;
274 }
275 $sourceUrl = isset($row['origin']) && is_string($row['origin']) ? trim($row['origin']) : '';
276 $destUrl = isset($row['target']) && is_string($row['target']) ? trim($row['target']) : '';
277 $code = isset($row['redirect_type']) && is_numeric($row['redirect_type'])
278 ? (int)$row['redirect_type']
279 : 301;
280
281 if ($sourceUrl === '' || $destUrl === '') {
282 continue;
283 }
284 yield array(
285 'source_url' => $sourceUrl,
286 'dest_url' => $destUrl,
287 'code' => $code,
288 'is_regex' => false,
289 );
290 }
291 }
292
293 /**
294 * Read AIOSEO redirects from aioseo_redirects table.
295 *
296 * @return \Generator<int, array<string, mixed>>
297 */
298 private function readAIOSEO(): \Generator {
299 global $wpdb;
300
301 $tableName = $wpdb->prefix . 'aioseo_redirects';
302 if (!$this->queryGateway->tableExists($tableName)) {
303 return;
304 }
305
306 foreach ($this->pageTableQuery(
307 "SELECT source, target, type
308 FROM `{$tableName}`
309 WHERE status = 'active'"
310 ) as $row) {
311 if (!is_array($row)) {
312 continue;
313 }
314 $sourceUrl = isset($row['source']) && is_string($row['source']) ? trim($row['source']) : '';
315 $destUrl = isset($row['target']) && is_string($row['target']) ? trim($row['target']) : '';
316 $code = isset($row['type']) && is_numeric($row['type']) ? (int)$row['type'] : 301;
317
318 if ($sourceUrl === '' || $destUrl === '') {
319 continue;
320 }
321 yield array(
322 'source_url' => $sourceUrl,
323 'dest_url' => $destUrl,
324 'code' => $code,
325 'is_regex' => false,
326 );
327 }
328 }
329
330 /**
331 * Read Safe Redirect Manager redirects via the redirect_rule custom post
332 * type, one IMPORT_PAGE_SIZE page of posts at a time (M502: previously
333 * `posts_per_page => -1` loaded every published redirect_rule post --
334 * plus a get_post_meta() lookup per post -- into memory in one call).
335 * Pages via WP_Query's standard `paged` parameter until a page returns
336 * fewer posts than the page size.
337 *
338 * @return \Generator<int, array<string, mixed>>
339 */
340 private function readSafeRedirectManager(): \Generator {
341 if (!function_exists('get_posts')) {
342 return;
343 }
344
345 $paged = 1;
346 while (true) {
347 $posts = get_posts(array(
348 'post_type' => 'redirect_rule',
349 'posts_per_page' => self::IMPORT_PAGE_SIZE,
350 'paged' => $paged,
351 'post_status' => 'publish',
352 'orderby' => 'ID',
353 'order' => 'ASC',
354 ));
355
356 if (!is_array($posts) || empty($posts)) {
357 return;
358 }
359
360 foreach ($posts as $post) {
361 if (!is_object($post)) {
362 continue;
363 }
364 $postId = (int)$post->ID;
365 if ($postId === 0) {
366 continue;
367 }
368
369 $from = get_post_meta($postId, '_redirect_rule_from', true);
370 $to = get_post_meta($postId, '_redirect_rule_to', true);
371 $code = get_post_meta($postId, '_redirect_rule_status_code', true);
372
373 $from = is_string($from) ? trim($from) : '';
374 $to = is_string($to) ? trim($to) : '';
375 $code = is_numeric($code) ? (int)$code : 301;
376
377 if ($from === '' || $to === '') {
378 continue;
379 }
380 yield array(
381 'source_url' => $from,
382 'dest_url' => $to,
383 'code' => $code,
384 'is_regex' => false,
385 );
386 }
387
388 if (count($posts) < self::IMPORT_PAGE_SIZE) {
389 return;
390 }
391 $paged++;
392 }
393 }
394
395 /**
396 * Read Redirection plugin redirects from redirection_items table.
397 *
398 * @return \Generator<int, array<string, mixed>>
399 */
400 private function readRedirection(): \Generator {
401 global $wpdb;
402
403 $tableName = $wpdb->prefix . 'redirection_items';
404 if (!$this->queryGateway->tableExists($tableName)) {
405 return;
406 }
407
408 foreach ($this->pageTableQuery(
409 "SELECT url, action_data, action_code, regex
410 FROM `{$tableName}`
411 WHERE status = 'enabled'"
412 ) as $row) {
413 if (!is_array($row)) {
414 continue;
415 }
416 $sourceUrl = isset($row['url']) && is_string($row['url']) ? trim($row['url']) : '';
417 $destUrl = isset($row['action_data']) && is_string($row['action_data']) ? trim($row['action_data']) : '';
418 $code = isset($row['action_code']) && is_numeric($row['action_code'])
419 ? (int)$row['action_code']
420 : 301;
421 $isRegex = !empty($row['regex']) && $row['regex'] != '0';
422
423 if ($sourceUrl === '' || $destUrl === '') {
424 continue;
425 }
426 yield array(
427 'source_url' => $sourceUrl,
428 'dest_url' => $destUrl,
429 'code' => $code,
430 'is_regex' => $isRegex,
431 );
432 }
433 }
434
435 /**
436 * Issue a COUNT(*) against a source-plugin table, through the same
437 * gateway queryRows() uses for full reads, mirroring the WHERE clause
438 * the row-reading method for that source applies. Returns 0 (rather
439 * than throwing) when the table is absent or the query fails -- the
440 * caller treats "nothing importable" and "can't tell" the same way the
441 * existing preview path already does.
442 *
443 * @param string $tableName Fully-prefixed table name
444 * @param string $whereClause SQL WHERE condition without the "WHERE " keyword, or '' for none
445 * @return int
446 */
447 private function countTableRows(string $tableName, string $whereClause): int {
448 if (!$this->queryGateway->tableExists($tableName)) {
449 return 0;
450 }
451
452 $sql = "SELECT COUNT(*) AS cnt FROM `{$tableName}`";
453 if ($whereClause !== '') {
454 $sql .= " WHERE {$whereClause}";
455 }
456
457 $rows = $this->queryGateway->queryRows($sql);
458 if (empty($rows) || !is_array($rows[0])) {
459 return 0;
460 }
461
462 // Case-insensitive key lookup: information_schema-style result keys
463 // vary in case across MySQL/MariaDB drivers/versions (defensive
464 // coding rule: case-insensitive metadata access).
465 foreach ($rows[0] as $key => $value) {
466 if (strcasecmp((string)$key, 'cnt') === 0) {
467 return is_numeric($value) ? (int)$value : 0;
468 }
469 }
470 return 0;
471 }
472
473 /**
474 * Count Safe Redirect Manager rows via wp_count_posts(), matching the
475 * post_status filter readSafeRedirectManager() applies ('publish').
476 * wp_count_posts() runs a single grouped COUNT query against wp_posts;
477 * unlike get_posts(), it never loads full post objects or postmeta, so
478 * it is the count-only counterpart for a CPT-backed source exactly as
479 * SELECT COUNT(*) is for a DB-table-backed source.
480 *
481 * @return int
482 */
483 private function countSafeRedirectManager(): int {
484 if (!function_exists('post_type_exists') || !post_type_exists('redirect_rule')) {
485 return 0;
486 }
487 if (!function_exists('wp_count_posts')) {
488 return 0;
489 }
490
491 $counts = wp_count_posts('redirect_rule');
492 if (!is_object($counts) || !isset($counts->publish)) {
493 return 0;
494 }
495 return is_numeric($counts->publish) ? (int)$counts->publish : 0;
496 }
497 }
498