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 / wpcli / WPCLIRedirectCommandService.php

WPCLIRedirectCommandService.php in 404 Solution trunk, at includes/wpcli/WPCLIRedirectCommandService.php

353 lines 13.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 // allow-no-test-found: covered through public WP-CLI command entry points in tests/WPCLICommandsTest.php
8
9 /**
10 * Application service for WP-CLI redirect commands.
11 */
12 class ABJ_404_Solution_WPCLIRedirectCommandService {
13
14 /** @return array<int, string> */
15 public function validStatusLabels(): array {
16 return array('manual', 'auto', 'captured', 'regex', 'ignored', 'later');
17 }
18
19 /**
20 * @param string $status
21 * @param string $format
22 * @return array<string, mixed>
23 */
24 public function listRedirects(string $status, string $format): array {
25 if ($status !== '' && !in_array($status, $this->validStatusLabels(), true)) {
26 return $this->error('Invalid --status value. Choose one of: ' . implode(', ', $this->validStatusLabels()));
27 }
28
29 $rows = $this->fetchRedirectRows(abj_service('db_core'), $this->statusStringToTypes($status), 2000);
30 if (empty($rows)) {
31 return $this->line('No redirects found.');
32 }
33
34 foreach ($rows as &$row) {
35 $rawStatus = $row['status'] ?? 0;
36 $row['status'] = $this->statusIntToLabel(is_numeric($rawStatus) ? (int)$rawStatus : 0);
37 }
38 unset($row);
39
40 return array(
41 'type' => 'format',
42 'format' => $format,
43 'rows' => $rows,
44 'fields' => array('id', 'url', 'status', 'type', 'final_dest', 'code', 'disabled', 'timestamp'),
45 );
46 }
47
48 /**
49 * @param string $from
50 * @param string $to
51 * @param int $code
52 * @param bool $regex
53 * @return array<string, mixed>
54 */
55 public function createRedirect(string $from, string $to, int $code, bool $regex): array {
56 if ($from === '') {
57 return $this->error('--from is required.');
58 }
59
60 $warnings = array();
61 if ($from[0] !== '/' && !preg_match('#^https?://#i', $from)) {
62 $warnings[] = "--from '{$from}' does not start with '/'. Incoming requests are matched against the path (e.g. /old-page), so this redirect may never fire.";
63 }
64
65 $validCodes = array(301, 302, 307, 308, 410, 451);
66 if (!in_array($code, $validCodes, true)) {
67 $warnings[] = 'Invalid redirect code; defaulting to 301. Valid codes: ' . implode(', ', $validCodes);
68 $code = 301;
69 }
70
71 $isTerminalCode = in_array($code, array(410, 451), true);
72 if ($to === '' && !$isTerminalCode) {
73 return $this->error('--to is required (omit only when --code is 410 or 451).', $warnings);
74 }
75 if (!$isTerminalCode && !$this->isValidDestination($to)) {
76 return $this->error('Invalid --to value. Use an absolute http(s) URL or a site-relative path starting with /.', $warnings);
77 }
78
79 if ($isTerminalCode) {
80 $dest = '0';
81 $type = (string)ABJ404_TYPE_404_DISPLAYED;
82 } else {
83 $resolved = $this->resolveDestinationType($to);
84 $type = $resolved['type'];
85 $dest = $resolved['dest'];
86 }
87
88 $status = $regex ? (string)ABJ404_STATUS_REGEX : (string)ABJ404_STATUS_MANUAL;
89 $insertedId = abj_service('redirects_repository')->setupRedirect(
90 ABJ_404_Solution_RedirectSpec::create($from, $status, $type, $dest, (string)$code, 0, 'wp-cli')
91 );
92 if (!$insertedId) {
93 return $this->error('Failed to create redirect. Check that the source URL is unique.', $warnings);
94 }
95
96 $displayDest = $isTerminalCode ? '(none ' . "\xE2\x80\x94" . " {$code})" : $to;
97 return $this->success("Redirect created (ID: {$insertedId}): {$from} " . "\xE2\x86\x92" . " {$displayDest} [{$code}]", $warnings);
98 }
99
100 /**
101 * @param string $idOrUrl
102 * @return array<string, mixed>
103 */
104 public function deleteRedirect(string $idOrUrl): array {
105 if ($idOrUrl === '') {
106 return $this->error('Please provide a redirect ID or source URL.');
107 }
108
109 $redirectsRepository = abj_service('redirects_repository');
110 $messages = array();
111 if (ctype_digit($idOrUrl)) {
112 $id = (int)$idOrUrl;
113 if ($id === 0) {
114 return $this->error('Invalid redirect ID.');
115 }
116 } else {
117 $redirect = $redirectsRepository->getExistingRedirectForURL($idOrUrl);
118 if (!isset($redirect['id']) || (int)(is_scalar($redirect['id']) ? $redirect['id'] : 0) === 0) {
119 return $this->error("No redirect found for URL: {$idOrUrl}");
120 }
121 $id = (int)(is_scalar($redirect['id']) ? $redirect['id'] : 0);
122 $messages[] = "Resolved '{$idOrUrl}' to redirect ID {$id}.";
123 }
124
125 $error = $redirectsRepository->moveRedirectsToTrash($id, 1);
126 if ($error !== '') {
127 return $this->error("No redirect with ID {$id} found, or database error: {$error}", array(), $messages);
128 }
129
130 return $this->success("Redirect ID {$id} moved to trash.", array(), $messages);
131 }
132
133 /**
134 * @param string $type
135 * @return array<string, mixed>
136 */
137 public function preparePurge(string $type): array {
138 if ($type !== 'captured') {
139 return $this->error('Only "captured" is a valid purge target. Usage: wp abj404 purge captured');
140 }
141
142 $count = $this->countCapturedRows();
143 if ($count === 0) {
144 return $this->line('No captured 404 entries to purge.');
145 }
146
147 return array('type' => 'confirm', 'count' => $count);
148 }
149
150 /**
151 * @param int $count
152 * @return array<string, mixed>
153 */
154 public function purgeCaptured(int $count): array {
155 $deleteResult = abj_service('db_core')->queryAndGetResults(
156 "DELETE FROM `{$this->redirectsTable()}` WHERE status IN ({$this->capturedStatusSql()}) AND disabled = 0"
157 );
158
159 $deleteError = isset($deleteResult['last_error']) && is_string($deleteResult['last_error']) ? $deleteResult['last_error'] : '';
160 if ($deleteError !== '') {
161 return $this->error('Database error: ' . $deleteError);
162 }
163
164 $deleted = isset($deleteResult['rows_affected']) && is_scalar($deleteResult['rows_affected'])
165 ? (int)$deleteResult['rows_affected']
166 : $count;
167 return $this->success("Purged {$deleted} captured 404 entries.");
168 }
169
170 /**
171 * @param string $url
172 * @return array<string, mixed>
173 */
174 public function testRedirect(string $url): array {
175 if ($url === '') {
176 return $this->error('Please provide a URL to test. Usage: wp abj404 test <url>');
177 }
178
179 $exactResult = $this->findExactRedirectMatch($url);
180 if ($exactResult !== null) {
181 return $exactResult;
182 }
183
184 $regexResult = $this->findRegexRedirectMatch($url);
185 if ($regexResult !== null) {
186 return $regexResult;
187 }
188
189 return $this->line("No redirect found for: {$url}");
190 }
191
192 /**
193 * @param string $url
194 * @return array{type: string, message: string, warnings: array<int, string>, lines: array<int, string>}|null
195 */
196 private function findExactRedirectMatch(string $url) {
197 $exact = abj_service('redirects_repository')->getExistingRedirectForURL($url);
198 if (isset($exact['id']) && (int)(is_scalar($exact['id']) ? $exact['id'] : 0) !== 0) {
199 $dest = isset($exact['final_dest']) && is_scalar($exact['final_dest']) ? (string)$exact['final_dest'] : '';
200 $code = isset($exact['code']) && is_scalar($exact['code']) ? (string)$exact['code'] : '301';
201 $exactId = is_scalar($exact['id']) ? (string)$exact['id'] : '?';
202 return $this->success("Exact match found (ID: {$exactId}): {$url} " . "\xE2\x86\x92" . " {$dest} [{$code}]");
203 }
204 return null;
205 }
206
207 /**
208 * @param string $url
209 * @return array{type: string, message: string, warnings: array<int, string>, lines: array<int, string>}|null
210 */
211 private function findRegexRedirectMatch(string $url) {
212 $f = abj_service('functions');
213 foreach (abj_service('view_read_service')->getRedirectsWithRegEx() as $row) {
214 $pattern = isset($row['url']) && is_scalar($row['url']) ? (string)$row['url'] : '';
215 if ($pattern === '') {
216 continue;
217 }
218 $matches = array();
219 if ($f->regexMatch($pattern, $url, $matches)) {
220 $dest = isset($row['final_dest']) && is_scalar($row['final_dest']) ? (string)$row['final_dest'] : '';
221 $code = isset($row['code']) && is_scalar($row['code']) ? (string)$row['code'] : '301';
222 $id = isset($row['id']) && is_scalar($row['id']) ? (string)$row['id'] : '?';
223 return $this->success("Regex match found (ID: {$id}, pattern: {$pattern}): {$url} " . "\xE2\x86\x92" . " {$dest} [{$code}]");
224 }
225 }
226 return null;
227 }
228
229 /** @return int */
230 private function countCapturedRows(): int {
231 return abj_service('db_core')->queryScalarInt(
232 "SELECT COUNT(*) AS c FROM `{$this->redirectsTable()}` WHERE status IN ({$this->capturedStatusSql()}) AND disabled = 0"
233 );
234 }
235
236 /**
237 * @param ABJ_404_Solution_DatabaseQueryInterface $dbCore
238 * @param array<int, int> $types
239 * @param int $limit
240 * @return array<int, array<string, mixed>>
241 */
242 private function fetchRedirectRows($dbCore, array $types, int $limit): array {
243 $where = '';
244 if (!empty($types)) {
245 $where = 'WHERE status IN (' . implode(', ', array_map('absint', $types)) . ')';
246 }
247
248 $result = $dbCore->queryAndGetResults(
249 "SELECT id, url, status, type, final_dest, code, disabled, timestamp
250 FROM `{$dbCore->doTableNameReplacements('{wp_abj404_redirects}')}`
251 {$where}
252 ORDER BY id DESC
253 LIMIT " . absint($limit)
254 );
255
256 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
257 $output = array();
258 foreach ($rows as $row) {
259 if (is_array($row)) {
260 $output[] = $row;
261 }
262 }
263 return $output;
264 }
265
266 /**
267 * @param string $status
268 * @return array<int, int>
269 */
270 private function statusStringToTypes(string $status): array {
271 $map = array(
272 'manual' => ABJ404_STATUS_MANUAL,
273 'auto' => ABJ404_STATUS_AUTO,
274 'captured' => ABJ404_STATUS_CAPTURED,
275 'ignored' => ABJ404_STATUS_IGNORED,
276 'later' => ABJ404_STATUS_LATER,
277 'regex' => ABJ404_STATUS_REGEX,
278 );
279 return isset($map[$status]) ? array($map[$status]) : array();
280 }
281
282 private function statusIntToLabel(int $status): string {
283 $map = array(
284 ABJ404_STATUS_MANUAL => 'manual',
285 ABJ404_STATUS_AUTO => 'auto',
286 ABJ404_STATUS_CAPTURED => 'captured',
287 ABJ404_STATUS_IGNORED => 'ignored',
288 ABJ404_STATUS_LATER => 'later',
289 ABJ404_STATUS_REGEX => 'regex',
290 );
291 return isset($map[$status]) ? $map[$status] : (string)$status;
292 }
293
294 /** @return array{type: string, dest: string} */
295 private function resolveDestinationType(string $to): array {
296 if (preg_match('#^https?://#i', $to)) {
297 return array('type' => (string)ABJ404_TYPE_EXTERNAL, 'dest' => $to);
298 }
299
300 $trimmed = trim($to, '/ ');
301 if ($trimmed === '') {
302 return array('type' => (string)ABJ404_TYPE_HOME, 'dest' => (string)ABJ404_TYPE_HOME);
303 }
304
305 if (function_exists('url_to_postid') && function_exists('home_url')) {
306 $postId = url_to_postid(home_url($to));
307 if ($postId > 0) {
308 return array('type' => (string)ABJ404_TYPE_POST, 'dest' => (string)$postId);
309 }
310 }
311
312 return array('type' => (string)ABJ404_TYPE_EXTERNAL, 'dest' => $to);
313 }
314
315 private function isValidDestination(string $to): bool {
316 if (preg_match('#^https?://#i', $to)) {
317 return filter_var($to, FILTER_VALIDATE_URL) !== false;
318 }
319 return isset($to[0]) && $to[0] === '/' && (!isset($to[1]) || $to[1] !== '/');
320 }
321
322 private function redirectsTable(): string {
323 return abj_service('db_core')->doTableNameReplacements('{wp_abj404_redirects}');
324 }
325
326 private function capturedStatusSql(): string {
327 return implode(', ', array(ABJ404_STATUS_CAPTURED, ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER));
328 }
329
330 /**
331 * @param array<int, string> $warnings
332 * @param array<int, string> $lines
333 * @return array{type: string, message: string, warnings: array<int, string>, lines: array<int, string>}
334 */
335 private function error(string $message, array $warnings = array(), array $lines = array()): array {
336 return array('type' => 'error', 'message' => $message, 'warnings' => $warnings, 'lines' => $lines);
337 }
338
339 /** @return array<string, mixed> */
340 private function line(string $message): array {
341 return array('type' => 'line', 'message' => $message);
342 }
343
344 /**
345 * @param array<int, string> $warnings
346 * @param array<int, string> $lines
347 * @return array{type: string, message: string, warnings: array<int, string>, lines: array<int, string>}
348 */
349 private function success(string $message, array $warnings = array(), array $lines = array()): array {
350 return array('type' => 'success', 'message' => $message, 'warnings' => $warnings, 'lines' => $lines);
351 }
352 }
353