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 / RestApiController.php

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

722 lines 28.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 * REST API controller for the abj404/v1 namespace.
9 *
10 * Registers routes for managing redirects, captured 404s, logs, stats,
11 * and redirect simulation (POST /test).
12 *
13 * Authentication: WordPress REST nonce (cookie) or application passwords.
14 * Permission: manage_options capability required for all endpoints.
15 */
16 class ABJ_404_Solution_RestApiController {
17
18 const NAMESPACE = 'abj404/v1';
19
20 /** @var ABJ_404_Solution_ViewReadService */
21 private $viewRead;
22
23 /** @var ABJ_404_Solution_ViewBuildOrchestrator */
24 private $viewBuild;
25
26 /** @var ABJ_404_Solution_RedirectsRepository */
27 private $redirectsRepo;
28
29 /** @var ABJ_404_Solution_LogsRepository */
30 private $logsRepo;
31
32 /** @var ABJ_404_Solution_StatsRepository */
33 private $statsRepo;
34
35 /** @var ABJ_404_Solution_DatabaseCore */
36 private $dbCore;
37
38 /** @var ABJ_404_Solution_PluginLogic */
39 private $logic;
40
41 /**
42 * @param object $daoOrLogic Legacy: DataAccess + PluginLogic. New: just PluginLogic.
43 * @param ABJ_404_Solution_PluginLogic|null $logic
44 */
45 public function __construct($daoOrLogic, $logic = null) {
46 if ($logic !== null) {
47 $this->logic = $logic;
48 $this->viewRead = $daoOrLogic;
49 $this->viewBuild = $daoOrLogic;
50 $this->redirectsRepo = $daoOrLogic;
51 $this->logsRepo = $daoOrLogic;
52 $this->statsRepo = $daoOrLogic;
53 $this->dbCore = $daoOrLogic;
54 return;
55 } else {
56 $this->logic = $daoOrLogic;
57 }
58 $this->viewRead = abj_service('view_read_service');
59 $this->viewBuild = abj_service('view_build_orchestrator');
60 $this->redirectsRepo = abj_service('redirects_repository');
61 $this->logsRepo = abj_service('logs_repository');
62 $this->statsRepo = abj_service('stats_repository');
63 $this->dbCore = abj_service('db_core');
64 }
65
66 /** @return void */
67 public function register() {
68 add_action('rest_api_init', array($this, 'registerRoutes'));
69 // Hide the plugin namespace from the public REST index to reduce fingerprinting.
70 // Authenticated access is unaffected — routes still work normally.
71 add_filter('rest_index_data', array($this, 'hideNamespaceFromIndex'));
72 }
73
74 /**
75 * Remove this plugin's namespace from the publicly-enumerable namespace list
76 * returned by GET /wp-json/. All routes remain accessible to authenticated
77 * requests; this only prevents unauthenticated namespace discovery.
78 *
79 * @param array<string,mixed> $data
80 * @return array<string,mixed>
81 */
82 public function hideNamespaceFromIndex($data) {
83 if (is_array($data) && isset($data['namespaces']) && is_array($data['namespaces'])) {
84 $data['namespaces'] = array_values(
85 array_filter($data['namespaces'], function ($ns) {
86 return $ns !== self::NAMESPACE;
87 })
88 );
89 }
90 return $data;
91 }
92
93 /** @return void */
94 public function registerRoutes() {
95 register_rest_route(self::NAMESPACE, '/redirects', array(
96 array(
97 'methods' => 'GET',
98 'callback' => array($this, 'getRedirects'),
99 'permission_callback' => array($this, 'permissionCheck'),
100 'args' => array(
101 'page' => array('type' => 'integer', 'default' => 1, 'minimum' => 1),
102 'per_page' => array('type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100),
103 'status' => array('type' => 'string', 'default' => ''),
104 'filter' => array('type' => 'string', 'default' => ''),
105 ),
106 ),
107 array(
108 'methods' => 'POST',
109 'callback' => array($this, 'createRedirect'),
110 'permission_callback' => array($this, 'permissionCheck'),
111 'args' => array(
112 'from' => array('type' => 'string', 'required' => true),
113 'to' => array('type' => 'string', 'required' => true),
114 'code' => array('type' => 'integer', 'default' => 301),
115 'regex' => array('type' => 'boolean', 'default' => false),
116 ),
117 ),
118 ));
119
120 register_rest_route(self::NAMESPACE, '/redirects/(?P<id>\d+)', array(
121 array(
122 'methods' => 'PUT',
123 'callback' => array($this, 'updateRedirect'),
124 'permission_callback' => array($this, 'permissionCheck'),
125 'args' => array(
126 'id' => array('type' => 'integer', 'required' => true),
127 'from' => array('type' => 'string'),
128 'to' => array('type' => 'string'),
129 'code' => array('type' => 'integer'),
130 'regex' => array('type' => 'boolean'),
131 ),
132 ),
133 array(
134 'methods' => 'DELETE',
135 'callback' => array($this, 'deleteRedirect'),
136 'permission_callback' => array($this, 'permissionCheck'),
137 'args' => array(
138 'id' => array('type' => 'integer', 'required' => true),
139 ),
140 ),
141 ));
142
143 register_rest_route(self::NAMESPACE, '/captured', array(
144 'methods' => 'GET',
145 'callback' => array($this, 'getCaptured'),
146 'permission_callback' => array($this, 'permissionCheck'),
147 'args' => array(
148 'page' => array('type' => 'integer', 'default' => 1, 'minimum' => 1),
149 'per_page' => array('type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100),
150 ),
151 ));
152
153 register_rest_route(self::NAMESPACE, '/captured/(?P<id>\d+)/redirect', array(
154 'methods' => 'POST',
155 'callback' => array($this, 'createRedirectFromCaptured'),
156 'permission_callback' => array($this, 'permissionCheck'),
157 'args' => array(
158 'id' => array('type' => 'integer', 'required' => true),
159 'to' => array('type' => 'string', 'required' => true),
160 'code' => array('type' => 'integer', 'default' => 301),
161 ),
162 ));
163
164 register_rest_route(self::NAMESPACE, '/stats', array(
165 'methods' => 'GET',
166 'callback' => array($this, 'getStats'),
167 'permission_callback' => array($this, 'permissionCheck'),
168 ));
169
170 register_rest_route(self::NAMESPACE, '/logs', array(
171 'methods' => 'GET',
172 'callback' => array($this, 'getLogs'),
173 'permission_callback' => array($this, 'permissionCheck'),
174 'args' => array(
175 'page' => array('type' => 'integer', 'default' => 1, 'minimum' => 1),
176 'per_page' => array('type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100),
177 ),
178 ));
179
180 register_rest_route(self::NAMESPACE, '/test', array(
181 'methods' => 'POST',
182 'callback' => array($this, 'testRedirect'),
183 'permission_callback' => array($this, 'permissionCheck'),
184 'args' => array(
185 'url' => array('type' => 'string', 'required' => true),
186 ),
187 ));
188 }
189
190 /**
191 * @param \WP_REST_Request $request
192 * @return bool
193 */
194 public function permissionCheck($request) {
195 return current_user_can('manage_options');
196 }
197
198 /**
199 * GET /redirects — list active redirects with optional filtering and pagination.
200 *
201 * @param \WP_REST_Request $request
202 * @return \WP_REST_Response|\WP_Error
203 */
204 public function getRedirects($request) {
205 $rawPage = $request->get_param('page');
206 $rawPerPage = $request->get_param('per_page');
207 $rawStatus = $request->get_param('status');
208 $rawFilter = $request->get_param('filter');
209
210 $page = max(1, absint(is_scalar($rawPage) ? $rawPage : 1));
211 $perPage = min(100, max(1, absint(is_scalar($rawPerPage) ? $rawPerPage : 20)));
212 $status = sanitize_text_field(is_scalar($rawStatus) ? (string)$rawStatus : '');
213 $filter = sanitize_text_field(is_scalar($rawFilter) ? (string)$rawFilter : '');
214
215 // $sub is the tab/view name — always 'abj404_redirects' for this endpoint.
216 // $statusFilter is the numeric status filter (0 = all active, or a specific status).
217 $sub = 'abj404_redirects';
218 $statusFilter = $this->statusStringToNumericFilter($status);
219
220 $tableOptions = array(
221 'orderby' => 'url',
222 'order' => 'ASC',
223 'paged' => $page,
224 'perpage' => $perPage,
225 'filter' => $statusFilter,
226 'logsid' => 0,
227 'sub' => $sub,
228 );
229
230 $rows = $this->viewRead->getRedirectsForView($sub, $tableOptions);
231 $total = $this->viewRead->getRedirectsForViewCount($sub, $tableOptions);
232
233 $rows = is_array($rows) ? $rows : array();
234
235 return new \WP_REST_Response(array(
236 'items' => array_values($rows),
237 'total' => intval($total),
238 'page' => $page,
239 'per_page' => $perPage,
240 'total_pages' => max(1, (int)ceil(intval($total) / $perPage)),
241 ), 200);
242 }
243
244 /**
245 * POST /redirects — create a manual redirect.
246 *
247 * @param \WP_REST_Request $request
248 * @return \WP_REST_Response|\WP_Error
249 */
250 public function createRedirect($request) {
251 $rawFrom = $request->get_param('from');
252 $rawTo = $request->get_param('to');
253 $rawCode = $request->get_param('code');
254 $rawRegex = $request->get_param('regex');
255
256 $from = trim(is_scalar($rawFrom) ? (string)$rawFrom : '');
257 $to = trim(is_scalar($rawTo) ? (string)$rawTo : '');
258 $code = absint(is_scalar($rawCode) ? $rawCode : 301);
259 $regex = (bool)$rawRegex;
260
261 if ($from === '') {
262 return new \WP_Error('missing_from', __('The "from" URL is required.', '404-solution'), array('status' => 400));
263 }
264 if ($to === '') {
265 return new \WP_Error('missing_to', __('The "to" URL is required.', '404-solution'), array('status' => 400));
266 }
267 if (!in_array($code, array(301, 302), true)) {
268 $code = 301;
269 }
270
271 // Determine status and type.
272 $status = $regex ? (string)ABJ404_STATUS_REGEX : (string)ABJ404_STATUS_MANUAL;
273 $resolved = $this->resolveDestinationType($to);
274 $type = $resolved['type'];
275 $dest = $resolved['dest'];
276
277 $insertedId = $this->redirectsRepo->setupRedirect($from, $status, (string)$type, $dest, (string)$code, 0, 'rest-api');
278
279 if (!$insertedId) {
280 return new \WP_Error('create_failed', __('Failed to create redirect.', '404-solution'), array('status' => 500));
281 }
282
283 $this->viewBuild->markViewDoneInvalidatedByAdminMutation();
284
285 return new \WP_REST_Response(array(
286 'id' => intval($insertedId),
287 'from' => $from,
288 'to' => $to,
289 'code' => $code,
290 'status' => $status,
291 ), 201);
292 }
293
294 /**
295 * PUT /redirects/{id} — update an existing redirect.
296 *
297 * @param \WP_REST_Request $request
298 * @return \WP_REST_Response|\WP_Error
299 */
300 public function updateRedirect($request) {
301 $rawId = $request->get_param('id');
302 $rawFrom = $request->get_param('from');
303 $rawTo = $request->get_param('to');
304 $rawCode = $request->get_param('code');
305 $rawRegex = $request->get_param('regex');
306
307 $id = absint(is_scalar($rawId) ? $rawId : 0);
308 $from = trim(is_scalar($rawFrom) ? (string)$rawFrom : '');
309 $to = trim(is_scalar($rawTo) ? (string)$rawTo : '');
310 $code = ($rawCode !== null) ? absint(is_scalar($rawCode) ? $rawCode : 301) : 301;
311
312 if ($id <= 0) {
313 return new \WP_Error('invalid_id', __('Invalid redirect ID.', '404-solution'), array('status' => 400));
314 }
315 if ($from === '' || $to === '') {
316 return new \WP_Error('missing_params', __('Both "from" and "to" parameters are required.', '404-solution'), array('status' => 400));
317 }
318 if (!in_array($code, array(301, 302), true)) {
319 $code = 301;
320 }
321
322 $isRegex = (bool)$rawRegex;
323 $statusType = $isRegex ? (string)ABJ404_STATUS_REGEX : (string)ABJ404_STATUS_MANUAL;
324 $resolved = $this->resolveDestinationType($to);
325 $type = $resolved['type'];
326 $dest = $resolved['dest'];
327
328 $error = $this->redirectsRepo->updateRedirect((int)$type, $dest, $from, $id, (string)$code, $statusType);
329
330 if ($error !== '') {
331 return new \WP_Error('update_failed', $error, array('status' => 500));
332 }
333
334 $this->viewBuild->markViewDoneInvalidatedByAdminMutation();
335
336 return new \WP_REST_Response(array(
337 'id' => $id,
338 'from' => $from,
339 'to' => $to,
340 'code' => $code,
341 'status' => $statusType,
342 ), 200);
343 }
344
345 /**
346 * DELETE /redirects/{id} — move redirect to trash (not permanent delete).
347 *
348 * @param \WP_REST_Request $request
349 * @return \WP_REST_Response|\WP_Error
350 */
351 public function deleteRedirect($request) {
352 $rawId = $request->get_param('id');
353 $id = absint(is_scalar($rawId) ? $rawId : 0);
354
355 if ($id <= 0) {
356 return new \WP_Error('invalid_id', __('Invalid redirect ID.', '404-solution'), array('status' => 400));
357 }
358
359 $error = $this->redirectsRepo->moveRedirectsToTrash($id, 1);
360
361 if ($error !== '') {
362 return new \WP_Error('trash_failed', $error, array('status' => 500));
363 }
364
365 $this->viewBuild->markViewDoneInvalidatedByAdminMutation();
366
367 return new \WP_REST_Response(array('trashed' => true, 'id' => $id), 200);
368 }
369
370 /**
371 * GET /captured — list captured 404 URLs with pagination.
372 *
373 * @param \WP_REST_Request $request
374 * @return \WP_REST_Response|\WP_Error
375 */
376 public function getCaptured($request) {
377 $rawPage = $request->get_param('page');
378 $rawPerPage = $request->get_param('per_page');
379
380 $page = max(1, absint(is_scalar($rawPage) ? $rawPage : 1));
381 $perPage = min(100, max(1, absint(is_scalar($rawPerPage) ? $rawPerPage : 20)));
382
383 $types = array(ABJ404_STATUS_CAPTURED, ABJ404_STATUS_IGNORED, ABJ404_STATUS_LATER);
384 $total = $this->viewRead->getRecordCount($types, 0);
385
386 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
387 $statusIn = implode(', ', array_map('absint', $types));
388 $limitStart = ($page - 1) * $perPage;
389
390 $queryResult = $this->dbCore->queryAndGetResults(
391 "SELECT id, url, status, type, final_dest, code, timestamp, disabled
392 FROM `{$redirectsTable}`
393 WHERE status IN ({$statusIn}) AND disabled = 0
394 ORDER BY url ASC
395 LIMIT %d, %d",
396 ['query_params' => [$limitStart, $perPage]]
397 );
398 $rows = is_array($queryResult['rows'] ?? null) ? $queryResult['rows'] : array();
399
400 return new \WP_REST_Response(array(
401 'items' => array_values($rows),
402 'total' => intval($total),
403 'page' => $page,
404 'per_page' => $perPage,
405 'total_pages' => max(1, (int)ceil(intval($total) / $perPage)),
406 ), 200);
407 }
408
409 /**
410 * POST /captured/{id}/redirect — promote a captured 404 to a manual redirect.
411 *
412 * @param \WP_REST_Request $request
413 * @return \WP_REST_Response|\WP_Error
414 */
415 public function createRedirectFromCaptured($request) {
416 $rawId = $request->get_param('id');
417 $rawTo = $request->get_param('to');
418 $rawCode = $request->get_param('code');
419
420 $id = absint(is_scalar($rawId) ? $rawId : 0);
421 $to = trim(is_scalar($rawTo) ? (string)$rawTo : '');
422 $code = absint(is_scalar($rawCode) ? $rawCode : 301);
423
424 if ($id <= 0) {
425 return new \WP_Error('invalid_id', __('Invalid captured 404 ID.', '404-solution'), array('status' => 400));
426 }
427 if ($to === '') {
428 return new \WP_Error('missing_to', __('The "to" URL is required.', '404-solution'), array('status' => 400));
429 }
430 if (!in_array($code, array(301, 302), true)) {
431 $code = 301;
432 }
433
434 // Load the captured row to get the "from" URL.
435 $rows = $this->redirectsRepo->getRedirectsByIDs(array($id));
436 if (empty($rows)) {
437 return new \WP_Error('not_found', __('Captured 404 not found.', '404-solution'), array('status' => 404));
438 }
439 $row = $rows[0];
440 $from = is_array($row) && isset($row['url']) && is_string($row['url']) ? $row['url'] : '';
441
442 if ($from === '') {
443 return new \WP_Error('bad_record', __('The captured 404 record has no URL.', '404-solution'), array('status' => 500));
444 }
445
446 $resolved = $this->resolveDestinationType($to);
447 $type = $resolved['type'];
448 $dest = $resolved['dest'];
449 $error = $this->redirectsRepo->updateRedirect((int)$type, $dest, $from, $id, (string)$code, (string)ABJ404_STATUS_MANUAL);
450
451 if ($error !== '') {
452 return new \WP_Error('update_failed', $error, array('status' => 500));
453 }
454
455 $this->viewBuild->markViewDoneInvalidatedByAdminMutation();
456
457 return new \WP_REST_Response(array(
458 'id' => $id,
459 'from' => $from,
460 'to' => $to,
461 'code' => $code,
462 ), 200);
463 }
464
465 /**
466 * GET /stats — return summary statistics.
467 *
468 * @param \WP_REST_Request $request
469 * @return \WP_REST_Response|\WP_Error
470 */
471 public function getStats($request) {
472 try {
473 $snapshot = $this->statsRepo->getStatsDashboardSnapshot(true);
474 // getStatsDashboardSnapshot always returns array{refreshed_at, hash, data}.
475 $data = is_array($snapshot['data']) ? $snapshot['data'] : array();
476
477 $redirects = isset($data['redirects']) && is_array($data['redirects']) ? $data['redirects'] : array();
478 $captured = isset($data['captured']) && is_array($data['captured']) ? $data['captured'] : array();
479
480 // Resolve each key once via `??` so the array access is bounded
481 // and Undefined-array-key warnings do not fire on a fresh-install
482 // state where the snapshot keys are absent. The ternary form
483 // `is_scalar($x['k'] ?? 0) ? $x['k'] : 0` reads the key twice
484 // and triggers the warning on the truthy branch.
485 $rAuto301 = $redirects['auto301'] ?? 0;
486 $rAuto302 = $redirects['auto302'] ?? 0;
487 $rManual301 = $redirects['manual301'] ?? 0;
488 $rManual302 = $redirects['manual302'] ?? 0;
489 $rTrashed = $redirects['trashed'] ?? 0;
490 $cCaptured = $captured['captured'] ?? 0;
491 $cIgnored = $captured['ignored'] ?? 0;
492 $cTrashed = $captured['trashed'] ?? 0;
493 $stats = array(
494 'redirects' => array(
495 'auto_301' => intval(is_scalar($rAuto301) ? $rAuto301 : 0),
496 'auto_302' => intval(is_scalar($rAuto302) ? $rAuto302 : 0),
497 'manual_301' => intval(is_scalar($rManual301) ? $rManual301 : 0),
498 'manual_302' => intval(is_scalar($rManual302) ? $rManual302 : 0),
499 'trashed' => intval(is_scalar($rTrashed) ? $rTrashed : 0),
500 ),
501 'captured' => array(
502 'captured' => intval(is_scalar($cCaptured) ? $cCaptured : 0),
503 'ignored' => intval(is_scalar($cIgnored) ? $cIgnored : 0),
504 'trashed' => intval(is_scalar($cTrashed) ? $cTrashed : 0),
505 ),
506 );
507
508 return new \WP_REST_Response($stats, 200);
509
510 } catch (\Throwable $e) {
511 return new \WP_Error('stats_error', $e->getMessage(), array('status' => 500));
512 }
513 }
514
515 /**
516 * GET /logs — return log entries with pagination.
517 *
518 * @param \WP_REST_Request $request
519 * @return \WP_REST_Response|\WP_Error
520 */
521 public function getLogs($request) {
522 $rawPage = $request->get_param('page');
523 $rawPerPage = $request->get_param('per_page');
524
525 $page = max(1, absint(is_scalar($rawPage) ? $rawPage : 1));
526 $perPage = min(100, max(1, absint(is_scalar($rawPerPage) ? $rawPerPage : 20)));
527
528 $tableOptions = array(
529 'orderby' => 'timestamp',
530 'order' => 'DESC',
531 'paged' => $page,
532 'perpage' => $perPage,
533 'logsid' => 0,
534 'filter' => '',
535 );
536
537 $rows = $this->logsRepo->getLogRecords($tableOptions);
538 $total = $this->viewRead->getLogsCount(0);
539
540 $rows = is_array($rows) ? $rows : array();
541
542 return new \WP_REST_Response(array(
543 'items' => array_values($rows),
544 'total' => intval($total),
545 'page' => $page,
546 'per_page' => $perPage,
547 'total_pages' => max(1, (int)ceil(intval($total) / $perPage)),
548 ), 200);
549 }
550
551 /**
552 * POST /test — simulate URL matching: given a URL, return what redirect would fire.
553 *
554 * @param \WP_REST_Request $request
555 * @return \WP_REST_Response|\WP_Error
556 */
557 public function testRedirect($request) {
558 // Distinguish "parameter omitted" (legitimate 400; the caller broke
559 // the API contract) from "parameter present but whitespace-only"
560 // (treat as malformed input and return matched=false). The latter
561 // is the documented robust-input contract: malformed URLs (control
562 // chars, invalid schemes, extreme length, etc.) must produce a
563 // 200 / matched=false response, not a 5xx. Whitespace-only is one
564 // shape of malformed input.
565 //
566 // Inference: WP_REST_Request->get_param('url') returns null when
567 // the parameter was not present in body/query/json/url params and
568 // returns the raw value (including empty string) when it was. The
569 // null vs scalar split is the safe portable signal across WP
570 // versions and across the minimal test stub that doesn't expose
571 // has_param().
572 $rawUrl = $request->get_param('url');
573 if ($rawUrl === null) {
574 return new \WP_Error('missing_url', __('The "url" parameter is required.', '404-solution'), array('status' => 400));
575 }
576 $url = trim(is_scalar($rawUrl) ? (string)$rawUrl : '');
577
578 // Normalize to relative path for lookup. Empty $url falls through to
579 // the lookup as-is; the DAO returns no match and the endpoint emits
580 // matched=false at 200, which is the malformed-input contract.
581 $normalizedUrl = $this->logic->normalizeToRelativePath($url);
582 if (!is_string($normalizedUrl) || $normalizedUrl === '') {
583 $normalizedUrl = $url;
584 }
585
586 // Check for an existing redirect stored in the database.
587 $redirect = $this->redirectsRepo->getExistingRedirectForURL($normalizedUrl);
588
589 if (!is_array($redirect) || empty($redirect) || !isset($redirect['id']) || !is_scalar($redirect['id']) || intval($redirect['id']) === 0) {
590 // Also check regex redirects.
591 $regexRedirects = $this->viewRead->getRedirectsWithRegEx();
592 $matchedRegex = null;
593 if (is_array($regexRedirects)) {
594 foreach ($regexRedirects as $rr) {
595 if (!is_array($rr) || empty($rr['url'])) {
596 continue;
597 }
598 $pattern = is_string($rr['url']) ? $rr['url'] : '';
599 // Patterns are stored without delimiters; wrap in {} like regexMatch() does.
600 $delimited = '{' . $pattern . '}';
601 if ($pattern !== '' && @preg_match($delimited, $normalizedUrl) === 1) {
602 $matchedRegex = $rr;
603 break;
604 }
605 }
606 }
607
608 if ($matchedRegex !== null) {
609 $rrId = is_scalar($matchedRegex['id'] ?? null) ? intval($matchedRegex['id']) : 0;
610 $rrDest = is_scalar($matchedRegex['final_dest'] ?? null) ? (string)$matchedRegex['final_dest'] : '';
611 $rrCode = is_scalar($matchedRegex['code'] ?? null) ? intval($matchedRegex['code']) : 301;
612
613 return new \WP_REST_Response(array(
614 'matched' => true,
615 'type' => 'regex',
616 'redirect_id' => $rrId,
617 'from' => $normalizedUrl,
618 'to' => $rrDest,
619 'code' => $rrCode,
620 ), 200);
621 }
622
623 return new \WP_REST_Response(array(
624 'matched' => false,
625 'url' => $normalizedUrl,
626 ), 200);
627 }
628
629 // A stored redirect was found.
630 $finalDest = isset($redirect['final_dest']) && is_string($redirect['final_dest']) ? $redirect['final_dest'] : '';
631
632 return new \WP_REST_Response(array(
633 'matched' => true,
634 'type' => 'stored',
635 'redirect_id' => intval(is_scalar($redirect['id']) ? $redirect['id'] : 0),
636 'from' => $normalizedUrl,
637 'to' => $finalDest,
638 'code' => intval(is_scalar($redirect['code'] ?? 301) ? ($redirect['code'] ?? 301) : 301),
639 'status' => intval(is_scalar($redirect['status'] ?? 0) ? ($redirect['status'] ?? 0) : 0),
640 ), 200);
641 }
642
643 // -----------------------------------------------------------------------
644 // Private helpers
645 // -----------------------------------------------------------------------
646
647 /**
648 * Map a status string to the 'sub' value expected by getRedirectsForView.
649 *
650 * @param string $status
651 * @return string
652 */
653 /**
654 * Convert a user-facing status string to the numeric filter value used by
655 * getRedirectsForView/getRedirectsForViewCount.
656 * '0' means "all active" (default).
657 *
658 * @param string $status
659 * @return string
660 */
661 private function statusStringToNumericFilter($status) {
662 switch (strtolower($status)) {
663 case 'manual':
664 return (string)ABJ404_STATUS_MANUAL;
665 case 'auto':
666 return (string)ABJ404_STATUS_AUTO;
667 case 'regex':
668 return (string)ABJ404_STATUS_REGEX;
669 default:
670 // 0 means "all active redirects".
671 return '0';
672 }
673 }
674
675 /**
676 * Resolve the redirect type and final destination for a given URL.
677 *
678 * ABJ404_TYPE_HOME (5) means "redirect to the home page" — permalinkInfoToArray()
679 * ignores the stored final_dest for this type. Internal paths must be resolved to
680 * a post ID (ABJ404_TYPE_POST) or stored as ABJ404_TYPE_EXTERNAL so the URL is
681 * preserved and used as-is by the redirect pipeline.
682 *
683 * @param string $to The destination URL provided by the API caller.
684 * @return array{type: int, dest: string}
685 */
686 private function resolveDestinationType($to) {
687 // External URLs (http/https).
688 if ($this->looksLikeExternalUrl($to)) {
689 return array('type' => (int)ABJ404_TYPE_EXTERNAL, 'dest' => $to);
690 }
691
692 // Home page: root path or empty string.
693 $trimmed = trim($to, '/ ');
694 if ($trimmed === '') {
695 return array('type' => (int)ABJ404_TYPE_HOME, 'dest' => (string)ABJ404_TYPE_HOME);
696 }
697
698 // Try to resolve the internal path to a WordPress post/page.
699 if (function_exists('url_to_postid')) {
700 $postId = url_to_postid(home_url($to));
701 if ($postId > 0) {
702 return array('type' => (int)ABJ404_TYPE_POST, 'dest' => (string)$postId);
703 }
704 }
705
706 // Unresolvable internal path — use EXTERNAL type so the URL is stored
707 // and used as-is by the redirect pipeline (FrontendRequestPipeline uses
708 // $redirectFinalDest directly for EXTERNAL type).
709 return array('type' => (int)ABJ404_TYPE_EXTERNAL, 'dest' => $to);
710 }
711
712 /**
713 * Return true if the URL starts with http:// or https://, indicating an external URL.
714 *
715 * @param string $url
716 * @return bool
717 */
718 private function looksLikeExternalUrl($url) {
719 return (strncasecmp($url, 'http://', 7) === 0 || strncasecmp($url, 'https://', 8) === 0);
720 }
721 }
722