PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / CrossPluginImporter.php

CrossPluginImporter.php in 404 Solution 4.2.0, at includes/CrossPluginImporter.php

470 lines 15.4 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 * Cross-plugin redirect importer.
9 *
10 * Detects installed redirect plugins by checking for their database tables,
11 * provides a preview of available redirects, and imports them into the
12 * 404 Solution redirects table.
13 *
14 * Supported sources:
15 * - Rank Math (rank_math_redirections)
16 * - Yoast SEO Premium (yoast_seo_redirects)
17 * - AIOSEO (aioseo_redirects)
18 * - Safe Redirect Manager (redirect_rule CPT)
19 * - Redirection plugin (redirection_items)
20 */
21 class ABJ_404_Solution_CrossPluginImporter {
22
23 /** @var ABJ_404_Solution_RedirectsRepositoryInterface|ABJ_404_Solution_DataAccess */
24 private $redirectsRepository;
25
26 /** @var ABJ_404_Solution_Logging */
27 private $logger;
28
29 /**
30 * @param ABJ_404_Solution_RedirectsRepositoryInterface|ABJ_404_Solution_DataAccess $redirectsRepository
31 * @param ABJ_404_Solution_Logging $logger
32 */
33 public function __construct($redirectsRepository, $logger) {
34 $this->redirectsRepository = $redirectsRepository;
35 $this->logger = $logger;
36 }
37
38 /**
39 * Detect which source plugins are installed by checking for their DB tables
40 * (or, for Safe Redirect Manager, by checking for the CPT).
41 *
42 * @return array<string, bool> e.g. ['rankmath' => true, 'redirection' => false, ...]
43 */
44 public function detectInstalledPlugins(): array {
45 global $wpdb;
46
47 if (!$wpdb) {
48 return array('rankmath' => false, 'yoast' => false, 'aioseo' => false,
49 'redirection' => false, 'safe-redirect-manager' => false);
50 }
51
52 $tableMap = array(
53 'rankmath' => $wpdb->prefix . 'rank_math_redirections',
54 'yoast' => $wpdb->prefix . 'yoast_seo_redirects',
55 'aioseo' => $wpdb->prefix . 'aioseo_redirects',
56 'redirection' => $wpdb->prefix . 'redirection_items',
57 );
58
59 $detected = array();
60 foreach ($tableMap as $slug => $tableName) {
61 $detected[$slug] = $this->tableExists($tableName);
62 }
63
64 // Safe Redirect Manager uses a custom post type, not a dedicated table.
65 // Detect it via the CPT registration rather than a table check.
66 $detected['safe-redirect-manager'] = function_exists('post_type_exists') && post_type_exists('redirect_rule');
67
68 return $detected;
69 }
70
71 /**
72 * Return a preview of redirects available from the given source plugin.
73 *
74 * @param string $source One of 'rankmath', 'yoast', 'aioseo', 'safe-redirect-manager', 'redirection'
75 * @param int $previewLimit Maximum rows to return for preview
76 * @return array<int, array<string, mixed>>
77 */
78 public function getImportPreview(string $source, int $previewLimit = 10): array {
79 $rows = $this->readSource($source);
80 return array_slice($rows, 0, $previewLimit);
81 }
82
83 /**
84 * Import all redirects from the given source plugin.
85 * Returns the number of redirects successfully imported.
86 *
87 * @param string $source
88 * @return int
89 */
90 public function importFrom(string $source): int {
91 $rows = $this->readSource($source);
92
93 if (empty($rows)) {
94 return 0;
95 }
96
97 $imported = 0;
98 foreach ($rows as $row) {
99 $sourceUrl = isset($row['source_url']) && is_string($row['source_url']) ? $row['source_url'] : '';
100 $destUrl = isset($row['dest_url']) && is_string($row['dest_url']) ? $row['dest_url'] : '';
101 $code = isset($row['code']) && is_numeric($row['code']) ? (int)$row['code'] : 301;
102 $isRegex = isset($row['is_regex']) && (bool)$row['is_regex'];
103
104 if ($sourceUrl === '' || $destUrl === '') {
105 continue;
106 }
107
108 $status = $isRegex ? ABJ404_STATUS_REGEX : ABJ404_STATUS_MANUAL;
109
110 // Determine destination type and resolve internal paths to post IDs.
111 $resolved = $this->resolveDestinationType($destUrl);
112 $type = $resolved['type'];
113 $destUrl = $resolved['dest'];
114
115 $result = $this->redirectsRepository->setupRedirect(
116 $sourceUrl,
117 (string)$status,
118 (string)$type,
119 $destUrl,
120 (string)$code,
121 0
122 );
123
124 if ($result !== 0 && $result !== false) {
125 $imported++;
126 }
127 }
128
129 $this->logger->infoMessage(
130 'CrossPluginImporter: imported ' . $imported . ' redirect(s) from "' . $source . '".'
131 );
132
133 return $imported;
134 }
135
136 // -------------------------------------------------------------------------
137 // Private reader methods
138 // -------------------------------------------------------------------------
139
140 /**
141 * Dispatch to the appropriate source-specific reader.
142 *
143 * @param string $source
144 * @return array<int, array<string, mixed>>
145 */
146 private function readSource(string $source): array {
147 switch ($source) {
148 case 'rankmath':
149 return $this->readRankMath();
150 case 'yoast':
151 return $this->readYoast();
152 case 'aioseo':
153 return $this->readAIOSEO();
154 case 'safe-redirect-manager':
155 return $this->readSafeRedirectManager();
156 case 'redirection':
157 return $this->readRedirection();
158 default:
159 $this->logger->debugMessage(
160 'CrossPluginImporter: unknown source "' . $source . '" — returning empty.'
161 );
162 return array();
163 }
164 }
165
166 /**
167 * Read Rank Math redirections from rank_math_redirections table.
168 *
169 * @return array<int, array<string, mixed>>
170 */
171 private function readRankMath(): array {
172 global $wpdb;
173
174 $tableName = $wpdb->prefix . 'rank_math_redirections';
175 if (!$this->tableExists($tableName)) {
176 return array();
177 }
178
179 // DAO-bypass-approved: Reading external plugin's table (Rank Math) — DAO would auto-CREATE
180 $rows = $wpdb->get_results(
181 "SELECT source_url, dest_url, redirect_type, regex_flag
182 FROM `{$tableName}`
183 WHERE status = 'active'",
184 ARRAY_A
185 );
186
187 if (!is_array($rows)) {
188 return array();
189 }
190
191 $result = array();
192 foreach ($rows as $row) {
193 if (!is_array($row)) {
194 continue;
195 }
196 $sourceUrl = isset($row['source_url']) && is_string($row['source_url']) ? trim($row['source_url']) : '';
197 $destUrl = isset($row['dest_url']) && is_string($row['dest_url']) ? trim($row['dest_url']) : '';
198 $code = isset($row['redirect_type']) && is_numeric($row['redirect_type'])
199 ? (int)$row['redirect_type']
200 : 301;
201 $isRegex = !empty($row['regex_flag']) && $row['regex_flag'] != '0';
202
203 if ($sourceUrl === '' || $destUrl === '') {
204 continue;
205 }
206 $result[] = array(
207 'source_url' => $sourceUrl,
208 'dest_url' => $destUrl,
209 'code' => $code,
210 'is_regex' => $isRegex,
211 );
212 }
213
214 return $result;
215 }
216
217 /**
218 * Read Yoast SEO Premium redirects from yoast_seo_redirects table.
219 *
220 * @return array<int, array<string, mixed>>
221 */
222 private function readYoast(): array {
223 global $wpdb;
224
225 $tableName = $wpdb->prefix . 'yoast_seo_redirects';
226 if (!$this->tableExists($tableName)) {
227 return array();
228 }
229
230 // DAO-bypass-approved: Reading external plugin's table (Yoast) — DAO would auto-CREATE
231 $rows = $wpdb->get_results(
232 "SELECT origin, target, redirect_type
233 FROM `{$tableName}`",
234 ARRAY_A
235 );
236
237 if (!is_array($rows)) {
238 return array();
239 }
240
241 $result = array();
242 foreach ($rows as $row) {
243 if (!is_array($row)) {
244 continue;
245 }
246 $sourceUrl = isset($row['origin']) && is_string($row['origin']) ? trim($row['origin']) : '';
247 $destUrl = isset($row['target']) && is_string($row['target']) ? trim($row['target']) : '';
248 $code = isset($row['redirect_type']) && is_numeric($row['redirect_type'])
249 ? (int)$row['redirect_type']
250 : 301;
251
252 if ($sourceUrl === '' || $destUrl === '') {
253 continue;
254 }
255 $result[] = array(
256 'source_url' => $sourceUrl,
257 'dest_url' => $destUrl,
258 'code' => $code,
259 'is_regex' => false,
260 );
261 }
262
263 return $result;
264 }
265
266 /**
267 * Read AIOSEO redirects from aioseo_redirects table.
268 *
269 * @return array<int, array<string, mixed>>
270 */
271 private function readAIOSEO(): array {
272 global $wpdb;
273
274 $tableName = $wpdb->prefix . 'aioseo_redirects';
275 if (!$this->tableExists($tableName)) {
276 return array();
277 }
278
279 // DAO-bypass-approved: Reading external plugin's table (AIOSEO) — DAO would auto-CREATE
280 $rows = $wpdb->get_results(
281 "SELECT source, target, type
282 FROM `{$tableName}`
283 WHERE status = 'active'",
284 ARRAY_A
285 );
286
287 if (!is_array($rows)) {
288 return array();
289 }
290
291 $result = array();
292 foreach ($rows as $row) {
293 if (!is_array($row)) {
294 continue;
295 }
296 $sourceUrl = isset($row['source']) && is_string($row['source']) ? trim($row['source']) : '';
297 $destUrl = isset($row['target']) && is_string($row['target']) ? trim($row['target']) : '';
298 $code = isset($row['type']) && is_numeric($row['type']) ? (int)$row['type'] : 301;
299
300 if ($sourceUrl === '' || $destUrl === '') {
301 continue;
302 }
303 $result[] = array(
304 'source_url' => $sourceUrl,
305 'dest_url' => $destUrl,
306 'code' => $code,
307 'is_regex' => false,
308 );
309 }
310
311 return $result;
312 }
313
314 /**
315 * Read Safe Redirect Manager redirects via the redirect_rule custom post type.
316 *
317 * @return array<int, array<string, mixed>>
318 */
319 private function readSafeRedirectManager(): array {
320 if (!function_exists('get_posts')) {
321 return array();
322 }
323
324 $posts = get_posts(array(
325 'post_type' => 'redirect_rule',
326 'posts_per_page' => -1,
327 'post_status' => 'publish',
328 ));
329
330 if (!is_array($posts)) {
331 return array();
332 }
333
334 $result = array();
335 foreach ($posts as $post) {
336 if (!is_object($post)) {
337 continue;
338 }
339 $postId = (int)$post->ID;
340 if ($postId === 0) {
341 continue;
342 }
343
344 $from = get_post_meta($postId, '_redirect_rule_from', true);
345 $to = get_post_meta($postId, '_redirect_rule_to', true);
346 $code = get_post_meta($postId, '_redirect_rule_status_code', true);
347
348 $from = is_string($from) ? trim($from) : '';
349 $to = is_string($to) ? trim($to) : '';
350 $code = is_numeric($code) ? (int)$code : 301;
351
352 if ($from === '' || $to === '') {
353 continue;
354 }
355 $result[] = array(
356 'source_url' => $from,
357 'dest_url' => $to,
358 'code' => $code,
359 'is_regex' => false,
360 );
361 }
362
363 return $result;
364 }
365
366 /**
367 * Read Redirection plugin redirects from redirection_items table.
368 *
369 * @return array<int, array<string, mixed>>
370 */
371 private function readRedirection(): array {
372 global $wpdb;
373
374 $tableName = $wpdb->prefix . 'redirection_items';
375 if (!$this->tableExists($tableName)) {
376 return array();
377 }
378
379 // DAO-bypass-approved: Reading external plugin's table (Redirection) — DAO would auto-CREATE
380 $rows = $wpdb->get_results(
381 "SELECT url, action_data, action_code, regex
382 FROM `{$tableName}`
383 WHERE status = 'enabled'",
384 ARRAY_A
385 );
386
387 if (!is_array($rows)) {
388 return array();
389 }
390
391 $result = array();
392 foreach ($rows as $row) {
393 if (!is_array($row)) {
394 continue;
395 }
396 $sourceUrl = isset($row['url']) && is_string($row['url']) ? trim($row['url']) : '';
397 $destUrl = isset($row['action_data']) && is_string($row['action_data']) ? trim($row['action_data']) : '';
398 $code = isset($row['action_code']) && is_numeric($row['action_code'])
399 ? (int)$row['action_code']
400 : 301;
401 $isRegex = !empty($row['regex']) && $row['regex'] != '0';
402
403 if ($sourceUrl === '' || $destUrl === '') {
404 continue;
405 }
406 $result[] = array(
407 'source_url' => $sourceUrl,
408 'dest_url' => $destUrl,
409 'code' => $code,
410 'is_regex' => $isRegex,
411 );
412 }
413
414 return $result;
415 }
416
417 // -------------------------------------------------------------------------
418 // Helpers
419 // -------------------------------------------------------------------------
420
421 /**
422 * Resolve the redirect type and final destination for a given URL.
423 *
424 * External URLs (http/https) use ABJ404_TYPE_EXTERNAL with the URL as-is.
425 * Internal paths are resolved via url_to_postid() — if a post ID is found,
426 * ABJ404_TYPE_POST is used with the numeric ID. Otherwise ABJ404_TYPE_EXTERNAL
427 * is used so the path is preserved and used as-is by the redirect pipeline.
428 *
429 * @param string $destUrl
430 * @return array{type: int, dest: string}
431 */
432 private function resolveDestinationType(string $destUrl): array {
433 if (preg_match('/^https?:\/\//i', $destUrl)) {
434 return array('type' => ABJ404_TYPE_EXTERNAL, 'dest' => $destUrl);
435 }
436
437 if (function_exists('url_to_postid') && function_exists('home_url')) {
438 $postId = url_to_postid(home_url($destUrl));
439 if ($postId > 0) {
440 return array('type' => ABJ404_TYPE_POST, 'dest' => (string)$postId);
441 }
442 }
443
444 return array('type' => ABJ404_TYPE_EXTERNAL, 'dest' => $destUrl);
445 }
446
447 /**
448 * Check whether a table exists using SHOW TABLES LIKE.
449 *
450 * @param string $tableName Fully-prefixed table name
451 * @return bool
452 */
453 private function tableExists(string $tableName): bool {
454 global $wpdb;
455
456 if (!$wpdb || !method_exists($wpdb, 'prepare') || !method_exists($wpdb, 'get_var')) {
457 return false;
458 }
459 /** @var \wpdb $wpdb */
460
461 // Use get_var so we get null on miss rather than an error.
462 // DAO-bypass-approved: tableExists() probe for external plugin's table
463 $result = $wpdb->get_var(
464 $wpdb->prepare('SHOW TABLES LIKE %s', $tableName)
465 );
466
467 return $result !== null && $result !== false && $result !== '';
468 }
469 }
470