PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 4.3.0, at includes/import/ForeignRedirectSourceReader.php

459 lines 14.9 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 * It makes no decision about how those rows become 404 Solution redirects; that
15 * business logic lives in {@see ABJ_404_Solution_CrossPluginImporter}.
16 *
17 * Supported sources:
18 * - Rank Math (rank_math_redirections)
19 * - Yoast SEO Premium (yoast_seo_redirects)
20 * - AIOSEO (aioseo_redirects)
21 * - Safe Redirect Manager (redirect_rule CPT)
22 * - Redirection plugin (redirection_items)
23 */
24 class ABJ_404_Solution_ForeignRedirectSourceReader {
25
26 /** @var ABJ_404_Solution_Logging */
27 private $logger;
28
29 /** @var ABJ_404_Solution_DatabaseQueryInterface|null */
30 private $dbQuery;
31
32 /**
33 * @param mixed $redirectsRepository Used only to resolve a database query
34 * service when one is not supplied directly.
35 * @param ABJ_404_Solution_Logging $logger
36 * @param ABJ_404_Solution_DatabaseQueryInterface|null $dbQuery
37 */
38 public function __construct($redirectsRepository, $logger, $dbQuery = null) {
39 $this->logger = $logger;
40 $this->dbQuery = $this->resolveDatabaseQuery($redirectsRepository, $dbQuery);
41 }
42
43 /**
44 * Detect which source plugins are installed by checking for their DB tables
45 * (or, for Safe Redirect Manager, by checking for the CPT).
46 *
47 * @return array<string, bool> e.g. ['rankmath' => true, 'redirection' => false, ...]
48 */
49 public function detectInstalledPlugins(): array {
50 global $wpdb;
51
52 if (!$wpdb) {
53 return array('rankmath' => false, 'yoast' => false, 'aioseo' => false,
54 'redirection' => false, 'safe-redirect-manager' => false);
55 }
56
57 $tableMap = array(
58 'rankmath' => $wpdb->prefix . 'rank_math_redirections',
59 'yoast' => $wpdb->prefix . 'yoast_seo_redirects',
60 'aioseo' => $wpdb->prefix . 'aioseo_redirects',
61 'redirection' => $wpdb->prefix . 'redirection_items',
62 );
63
64 $detected = array();
65 foreach ($tableMap as $slug => $tableName) {
66 $detected[$slug] = $this->tableExists($tableName);
67 }
68
69 // Safe Redirect Manager uses a custom post type, not a dedicated table.
70 // Detect it via the CPT registration rather than a table check.
71 $detected['safe-redirect-manager'] = function_exists('post_type_exists') && post_type_exists('redirect_rule');
72
73 return $detected;
74 }
75
76 /**
77 * Read and normalize all redirect rows from the given source plugin.
78 *
79 * @param string $source One of 'rankmath', 'yoast', 'aioseo',
80 * 'safe-redirect-manager', 'redirection'
81 * @return array<int, array<string, mixed>>
82 */
83 public function readSource(string $source): array {
84 switch ($source) {
85 case 'rankmath':
86 return $this->readRankMath();
87 case 'yoast':
88 return $this->readYoast();
89 case 'aioseo':
90 return $this->readAIOSEO();
91 case 'safe-redirect-manager':
92 return $this->readSafeRedirectManager();
93 case 'redirection':
94 return $this->readRedirection();
95 default:
96 $this->logger->debugMessage(
97 'CrossPluginImporter: unknown source "' . $source . '". Returning empty.'
98 );
99 return array();
100 }
101 }
102
103 /**
104 * Read Rank Math redirections from rank_math_redirections table.
105 *
106 * @return array<int, array<string, mixed>>
107 */
108 private function readRankMath(): array {
109 global $wpdb;
110
111 $tableName = $wpdb->prefix . 'rank_math_redirections';
112 if (!$this->tableExists($tableName)) {
113 return array();
114 }
115
116 $rows = $this->querySourceRows(
117 "SELECT source_url, dest_url, redirect_type, regex_flag
118 FROM `{$tableName}`
119 WHERE status = 'active'"
120 );
121
122 $result = array();
123 foreach ($rows as $row) {
124 if (!is_array($row)) {
125 continue;
126 }
127 $sourceUrl = isset($row['source_url']) && is_string($row['source_url']) ? trim($row['source_url']) : '';
128 $destUrl = isset($row['dest_url']) && is_string($row['dest_url']) ? trim($row['dest_url']) : '';
129 $code = isset($row['redirect_type']) && is_numeric($row['redirect_type'])
130 ? (int)$row['redirect_type']
131 : 301;
132 $isRegex = !empty($row['regex_flag']) && $row['regex_flag'] != '0';
133
134 if ($sourceUrl === '' || $destUrl === '') {
135 continue;
136 }
137 $result[] = array(
138 'source_url' => $sourceUrl,
139 'dest_url' => $destUrl,
140 'code' => $code,
141 'is_regex' => $isRegex,
142 );
143 }
144
145 return $result;
146 }
147
148 /**
149 * Read Yoast SEO Premium redirects from yoast_seo_redirects table.
150 *
151 * @return array<int, array<string, mixed>>
152 */
153 private function readYoast(): array {
154 global $wpdb;
155
156 $tableName = $wpdb->prefix . 'yoast_seo_redirects';
157 if (!$this->tableExists($tableName)) {
158 return array();
159 }
160
161 $rows = $this->querySourceRows(
162 "SELECT origin, target, redirect_type
163 FROM `{$tableName}`"
164 );
165
166 $result = array();
167 foreach ($rows as $row) {
168 if (!is_array($row)) {
169 continue;
170 }
171 $sourceUrl = isset($row['origin']) && is_string($row['origin']) ? trim($row['origin']) : '';
172 $destUrl = isset($row['target']) && is_string($row['target']) ? trim($row['target']) : '';
173 $code = isset($row['redirect_type']) && is_numeric($row['redirect_type'])
174 ? (int)$row['redirect_type']
175 : 301;
176
177 if ($sourceUrl === '' || $destUrl === '') {
178 continue;
179 }
180 $result[] = array(
181 'source_url' => $sourceUrl,
182 'dest_url' => $destUrl,
183 'code' => $code,
184 'is_regex' => false,
185 );
186 }
187
188 return $result;
189 }
190
191 /**
192 * Read AIOSEO redirects from aioseo_redirects table.
193 *
194 * @return array<int, array<string, mixed>>
195 */
196 private function readAIOSEO(): array {
197 global $wpdb;
198
199 $tableName = $wpdb->prefix . 'aioseo_redirects';
200 if (!$this->tableExists($tableName)) {
201 return array();
202 }
203
204 $rows = $this->querySourceRows(
205 "SELECT source, target, type
206 FROM `{$tableName}`
207 WHERE status = 'active'"
208 );
209
210 $result = array();
211 foreach ($rows as $row) {
212 if (!is_array($row)) {
213 continue;
214 }
215 $sourceUrl = isset($row['source']) && is_string($row['source']) ? trim($row['source']) : '';
216 $destUrl = isset($row['target']) && is_string($row['target']) ? trim($row['target']) : '';
217 $code = isset($row['type']) && is_numeric($row['type']) ? (int)$row['type'] : 301;
218
219 if ($sourceUrl === '' || $destUrl === '') {
220 continue;
221 }
222 $result[] = array(
223 'source_url' => $sourceUrl,
224 'dest_url' => $destUrl,
225 'code' => $code,
226 'is_regex' => false,
227 );
228 }
229
230 return $result;
231 }
232
233 /**
234 * Read Safe Redirect Manager redirects via the redirect_rule custom post type.
235 *
236 * @return array<int, array<string, mixed>>
237 */
238 private function readSafeRedirectManager(): array {
239 if (!function_exists('get_posts')) {
240 return array();
241 }
242
243 $posts = get_posts(array(
244 'post_type' => 'redirect_rule',
245 'posts_per_page' => -1,
246 'post_status' => 'publish',
247 ));
248
249 if (!is_array($posts)) {
250 return array();
251 }
252
253 $result = array();
254 foreach ($posts as $post) {
255 if (!is_object($post)) {
256 continue;
257 }
258 $postId = (int)$post->ID;
259 if ($postId === 0) {
260 continue;
261 }
262
263 $from = get_post_meta($postId, '_redirect_rule_from', true);
264 $to = get_post_meta($postId, '_redirect_rule_to', true);
265 $code = get_post_meta($postId, '_redirect_rule_status_code', true);
266
267 $from = is_string($from) ? trim($from) : '';
268 $to = is_string($to) ? trim($to) : '';
269 $code = is_numeric($code) ? (int)$code : 301;
270
271 if ($from === '' || $to === '') {
272 continue;
273 }
274 $result[] = array(
275 'source_url' => $from,
276 'dest_url' => $to,
277 'code' => $code,
278 'is_regex' => false,
279 );
280 }
281
282 return $result;
283 }
284
285 /**
286 * Read Redirection plugin redirects from redirection_items table.
287 *
288 * @return array<int, array<string, mixed>>
289 */
290 private function readRedirection(): array {
291 global $wpdb;
292
293 $tableName = $wpdb->prefix . 'redirection_items';
294 if (!$this->tableExists($tableName)) {
295 return array();
296 }
297
298 $rows = $this->querySourceRows(
299 "SELECT url, action_data, action_code, regex
300 FROM `{$tableName}`
301 WHERE status = 'enabled'"
302 );
303
304 $result = array();
305 foreach ($rows as $row) {
306 if (!is_array($row)) {
307 continue;
308 }
309 $sourceUrl = isset($row['url']) && is_string($row['url']) ? trim($row['url']) : '';
310 $destUrl = isset($row['action_data']) && is_string($row['action_data']) ? trim($row['action_data']) : '';
311 $code = isset($row['action_code']) && is_numeric($row['action_code'])
312 ? (int)$row['action_code']
313 : 301;
314 $isRegex = !empty($row['regex']) && $row['regex'] != '0';
315
316 if ($sourceUrl === '' || $destUrl === '') {
317 continue;
318 }
319 $result[] = array(
320 'source_url' => $sourceUrl,
321 'dest_url' => $destUrl,
322 'code' => $code,
323 'is_regex' => $isRegex,
324 );
325 }
326
327 return $result;
328 }
329
330 /**
331 * Check whether a table exists using SHOW TABLES LIKE.
332 *
333 * @param string $tableName Fully-prefixed table name
334 * @return bool
335 */
336 private function tableExists(string $tableName): bool {
337 if (!$this->dbQuery instanceof ABJ_404_Solution_DatabaseQueryInterface) {
338 $this->logger->warn(
339 'CrossPluginImporter: cannot check source table "' . $tableName . '" because no database query service is available.'
340 );
341 return false;
342 }
343
344 $result = $this->dbQuery->queryAndGetResults(
345 'SHOW TABLES LIKE %s',
346 array(
347 'query_params' => array($tableName),
348 'result_type' => defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A',
349 'log_errors' => false,
350 'skip_repair' => true,
351 )
352 );
353
354 if ($this->queryFailed($result)) {
355 $this->logger->warn(
356 'CrossPluginImporter: source table probe failed for "' . $tableName . '". Error: ' .
357 $this->queryErrorMessage($result)
358 );
359 return false;
360 }
361
362 return !empty($result['rows']) && is_array($result['rows']);
363 }
364
365 /**
366 * Read external source-plugin rows through the centralized query pipeline.
367 *
368 * @param string $sql
369 * @return array<int, array<string, mixed>>
370 */
371 private function querySourceRows(string $sql): array {
372 if (!$this->dbQuery instanceof ABJ_404_Solution_DatabaseQueryInterface) {
373 $this->logger->warn('CrossPluginImporter: cannot read source rows because no database query service is available.');
374 return array();
375 }
376
377 $result = $this->dbQuery->queryAndGetResults(
378 $sql,
379 array('result_type' => defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A')
380 );
381
382 if ($this->queryFailed($result)) {
383 $this->logger->warn(
384 'CrossPluginImporter: source row query failed. Error: ' . $this->queryErrorMessage($result)
385 );
386 return array();
387 }
388
389 $rows = $result['rows'] ?? array();
390 if (!is_array($rows)) {
391 return array();
392 }
393
394 $normalizedRows = array();
395 foreach ($rows as $row) {
396 if (is_array($row)) {
397 $normalizedRows[] = $row;
398 }
399 }
400 return $normalizedRows;
401 }
402
403 /**
404 * @param array<string, mixed> $result
405 * @return bool
406 */
407 private function queryFailed(array $result): bool {
408 if (($result['timed_out'] ?? false) === true) {
409 return true;
410 }
411 return $this->queryErrorMessage($result) !== '';
412 }
413
414 /**
415 * @param array<string, mixed> $result
416 * @return string
417 */
418 private function queryErrorMessage(array $result): string {
419 if (($result['timed_out'] ?? false) === true) {
420 return 'query timed out';
421 }
422
423 $error = $result['last_error'] ?? '';
424 if ($error === '') {
425 return '';
426 }
427 if (is_scalar($error)) {
428 return (string)$error;
429 }
430 if (is_object($error) && method_exists($error, '__toString')) {
431 return (string)$error;
432 }
433 return 'non-scalar database error of type ' . gettype($error);
434 }
435
436 /**
437 * Resolve the database query service without requiring existing callers
438 * to pass the optional constructor argument.
439 *
440 * @param mixed $redirectsRepository
441 * @param mixed $dbQuery
442 * @return ABJ_404_Solution_DatabaseQueryInterface|null
443 */
444 private function resolveDatabaseQuery($redirectsRepository, $dbQuery) {
445 if ($dbQuery instanceof ABJ_404_Solution_DatabaseQueryInterface) {
446 return $dbQuery;
447 }
448
449 if (is_object($redirectsRepository) && method_exists($redirectsRepository, 'getDbCore')) {
450 $candidate = $redirectsRepository->getDbCore();
451 if ($candidate instanceof ABJ_404_Solution_DatabaseQueryInterface) {
452 return $candidate;
453 }
454 }
455
456 return null;
457 }
458 }
459