PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.1.19, at includes/RestApiController.php

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