PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / api / class-instant-indexing-endpoint.php

class-instant-indexing-endpoint.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.7.0, at includes/api/class-instant-indexing-endpoint.php

695 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Instant Indexing API Endpoints Class
5 *
6 * REST API endpoints for Instant Indexing management including
7 * IndexNow settings, post type selection, and API key management.
8 *
9 * @package ThinkRank
10 * @subpackage API
11 * @since 1.0.0
12 */
13
14 declare(strict_types=1);
15
16 namespace ThinkRank\API;
17
18 use ThinkRank\Core\Settings;
19 use ThinkRank\SEO\Instant_Indexing_Reconciler;
20 use WP_REST_Controller;
21 use WP_REST_Request;
22 use WP_REST_Response;
23 use WP_Error;
24
25 // Prevent direct access
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * Instant Indexing API Endpoints Class
32 *
33 * Provides REST API endpoints for Instant Indexing operations.
34 *
35 * @since 1.0.0
36 */
37 class Instant_Indexing_Endpoint extends WP_REST_Controller {
38
39 /**
40 * API namespace
41 *
42 * @since 1.0.0
43 * @var string
44 */
45 protected $namespace = 'thinkrank/v1';
46
47 /**
48 * API resource base
49 *
50 * @since 1.0.0
51 * @var string
52 */
53 protected $rest_base = 'instant-indexing';
54
55 /**
56 * Settings option name
57 *
58 * @since 1.0.0
59 * @var string
60 */
61 private $option_name = 'thinkrank_instant_indexing_settings';
62
63 /**
64 * Reconciler instance
65 *
66 * @since 1.31.0
67 * @var Instant_Indexing_Reconciler|null
68 */
69 private ?Instant_Indexing_Reconciler $reconciler = null;
70
71 /**
72 * Register API routes
73 *
74 * @since 1.0.0
75 */
76 public function register_routes(): void {
77 // Get settings
78 register_rest_route(
79 $this->namespace,
80 '/' . $this->rest_base . '/settings',
81 [
82 [
83 'methods' => 'GET',
84 'callback' => [$this, 'get_settings'],
85 'permission_callback' => [$this, 'check_read_permissions']
86 ],
87 [
88 'methods' => 'POST',
89 'callback' => [$this, 'update_settings'],
90 'permission_callback' => [$this, 'check_manage_permissions'],
91 'args' => $this->get_settings_args()
92 ]
93 ]
94 );
95
96 // Get viewable post types
97 register_rest_route(
98 $this->namespace,
99 '/' . $this->rest_base . '/post-types',
100 [
101 [
102 'methods' => 'GET',
103 'callback' => [$this, 'get_post_types'],
104 'permission_callback' => [$this, 'check_read_permissions']
105 ]
106 ]
107 );
108
109 // Regenerate API Key
110 register_rest_route(
111 $this->namespace,
112 '/' . $this->rest_base . '/regenerate-key',
113 [
114 [
115 'methods' => 'POST',
116 'callback' => [$this, 'regenerate_api_key'],
117 'permission_callback' => [$this, 'check_manage_permissions']
118 ]
119 ]
120 );
121 // Submit URLs manually
122 register_rest_route(
123 $this->namespace,
124 '/' . $this->rest_base . '/submit',
125 [
126 [
127 'methods' => 'POST',
128 'callback' => [$this, 'submit_urls_to_api'],
129 'permission_callback' => [$this, 'check_manage_permissions'],
130 'args' => [
131 'urls' => [
132 'required' => true,
133 'type' => 'string', // Textarea content
134 'description' => 'List of URLs to submit'
135 ]
136 ]
137 ]
138 ]
139 );
140
141 // Verify the advertised key file is actually reachable (see #247).
142 register_rest_route(
143 $this->namespace,
144 '/' . $this->rest_base . '/verify-key',
145 [
146 [
147 'methods' => 'GET',
148 'callback' => [$this, 'verify_key'],
149 'permission_callback' => [$this, 'check_read_permissions']
150 ]
151 ]
152 );
153
154 // Get submission history
155 register_rest_route(
156 $this->namespace,
157 '/' . $this->rest_base . '/history',
158 [
159 [
160 'methods' => 'GET',
161 'callback' => [$this, 'get_submission_history'],
162 'permission_callback' => [$this, 'check_read_permissions'],
163 'args' => [
164 'limit' => [
165 'required' => false,
166 'type' => 'integer',
167 'default' => -1
168 ],
169 // Both are read by get_submission_history() and neither
170 // was registered, so they arrived uncoerced and
171 // unbounded (#394).
172 'page' => [
173 'required' => false,
174 'type' => 'integer',
175 'default' => 1,
176 'minimum' => 1,
177 ],
178 'per_page' => [
179 'required' => false,
180 'type' => 'integer',
181 'default' => 20,
182 'minimum' => 1,
183 'maximum' => 100,
184 ],
185 ]
186 ],
187 [
188 'methods' => 'DELETE',
189 'callback' => [$this, 'clear_submission_history'],
190 'permission_callback' => [$this, 'check_manage_permissions']
191 ]
192 ]
193 );
194
195 // Coverage report: which published URLs IndexNow actually knows about.
196 register_rest_route(
197 $this->namespace,
198 '/' . $this->rest_base . '/coverage',
199 [
200 [
201 'methods' => 'GET',
202 'callback' => [$this, 'get_coverage_report'],
203 'permission_callback' => [$this, 'check_read_permissions'],
204 'args' => [
205 'limit' => [
206 'required' => false,
207 'type' => 'integer',
208 'default' => Instant_Indexing_Reconciler::REPORT_LIMIT,
209 'minimum' => 1,
210 'maximum' => 2000
211 ],
212 'offset' => [
213 'required' => false,
214 'type' => 'integer',
215 'default' => 0,
216 'minimum' => 0
217 ]
218 ]
219 ]
220 ]
221 );
222
223 // Run a reconciliation pass now instead of waiting for the daily cron.
224 register_rest_route(
225 $this->namespace,
226 '/' . $this->rest_base . '/reconcile',
227 [
228 [
229 'methods' => 'POST',
230 // Resubmits URLs to a third party, so this needs the manage
231 // capability rather than the read one.
232 'callback' => [$this, 'run_reconciliation'],
233 'permission_callback' => [$this, 'check_manage_permissions'],
234 'args' => [
235 'dry_run' => [
236 'required' => false,
237 'type' => 'boolean',
238 'default' => false
239 ]
240 ]
241 ]
242 ]
243 );
244 }
245
246 /**
247 * Get the IndexNow coverage report.
248 *
249 * @since 1.31.0
250 *
251 * @param WP_REST_Request $request Request object
252 * @return WP_REST_Response Response object
253 */
254 public function get_coverage_report(WP_REST_Request $request): WP_REST_Response {
255 $report = $this->get_reconciler()->build_report(
256 (int) $request->get_param('limit'),
257 (int) $request->get_param('offset')
258 );
259
260 return new WP_REST_Response([
261 'success' => true,
262 'data' => $report,
263 ], 200);
264 }
265
266 /**
267 * Run a reconciliation pass on demand.
268 *
269 * @since 1.31.0
270 *
271 * @param WP_REST_Request $request Request object
272 * @return WP_REST_Response Response object
273 */
274 public function run_reconciliation(WP_REST_Request $request): WP_REST_Response {
275 $summary = $this->get_reconciler()->reconcile((bool) $request->get_param('dry_run'));
276
277 return new WP_REST_Response([
278 'success' => true,
279 'data' => $summary,
280 'message' => $summary['ran']
281 ? sprintf('Reconciliation complete: %d URLs examined, %d resubmitted.', $summary['examined'], $summary['retried'])
282 : $summary['reason'],
283 ], 200);
284 }
285
286 /**
287 * Reconciler instance, built on first use.
288 *
289 * @since 1.31.0
290 * @return Instant_Indexing_Reconciler
291 */
292 private function get_reconciler(): Instant_Indexing_Reconciler {
293 if (null === $this->reconciler) {
294 $this->reconciler = new Instant_Indexing_Reconciler();
295 }
296
297 return $this->reconciler;
298 }
299
300 /**
301 * Get settings
302 *
303 * @since 1.0.0
304 *
305 * @param WP_REST_Request $request Request object
306 * @return WP_REST_Response Response object
307 */
308 public function get_settings(WP_REST_Request $request): WP_REST_Response {
309 $settings = get_option($this->option_name, []);
310
311 $defaults = [
312 'enabled' => false,
313 'auto_submit_post_types' => ['post', 'page'],
314 'api_key' => ''
315 ];
316
317 $settings = wp_parse_args($settings, $defaults);
318
319 // Ensure api_key is always present
320 if (empty($settings['api_key'])) {
321 $settings['api_key'] = $this->generate_api_key();
322 $this->manage_key_file($settings['api_key']);
323 update_option($this->option_name, $settings);
324 } else {
325 // Verify file exists for existing key, create if missing
326 $file_path = ABSPATH . $settings['api_key'] . '.txt';
327 if (!file_exists($file_path)) {
328 $this->manage_key_file($settings['api_key']);
329 }
330 }
331
332 return new WP_REST_Response([
333 'success' => true,
334 'data' => $settings
335 ], 200);
336 }
337
338 /**
339 * Update settings
340 *
341 * @since 1.0.0
342 *
343 * @param WP_REST_Request $request Request object
344 * @return WP_REST_Response|WP_Error Response object or error
345 */
346 public function update_settings(WP_REST_Request $request) {
347 $params = $request->get_json_params();
348
349 if (empty($params)) {
350 $params = $request->get_params(); // Fallback if content-type is not JSON
351 }
352
353 $current_settings = get_option($this->option_name, []);
354 if (!is_array($current_settings)) {
355 $current_settings = [];
356 }
357 $new_settings = $current_settings;
358
359 // Only write the post types when the caller actually sent them. Writing
360 // unconditionally meant a payload of {"enabled": true} cleared the list,
361 // so the feature came on with nothing to submit — and diverged from the
362 // MCP ability, which writes this same option with an array_key_exists()
363 // merge. An explicit empty array still clears, since isset() is true
364 // for one (#562).
365 if (isset($params['auto_submit_post_types'])) {
366 $new_settings['auto_submit_post_types'] = array_values(array_map(
367 'sanitize_key',
368 (array) $params['auto_submit_post_types']
369 ));
370 }
371
372 // Save enabled state
373 if (isset($params['enabled'])) {
374 $new_settings['enabled'] = rest_sanitize_boolean($params['enabled']);
375 }
376
377 // If API key is provided and different (rare case), sanitize and validate
378 // it. The key is used to build a file path under ABSPATH, so it must be a
379 // plain hex token — reject anything else (e.g. path-traversal sequences).
380 if (isset($params['api_key'])) {
381 $candidate_key = sanitize_text_field($params['api_key']);
382 if (!preg_match('/^[a-f0-9]{8,64}$/', $candidate_key)) {
383 return new WP_REST_Response([
384 'success' => false,
385 'message' => __('Invalid API key format. It must be 8–64 hexadecimal characters.', 'thinkrank'),
386 ], 400);
387 }
388 $new_settings['api_key'] = $candidate_key;
389 }
390
391 update_option($this->option_name, $new_settings);
392
393 return new WP_REST_Response([
394 'success' => true,
395 'message' => __('Settings updated successfully', 'thinkrank'),
396 'data' => $new_settings
397 ], 200);
398 }
399
400 /**
401 * Get viewable post types
402 *
403 * Uses custom args as per requirements.
404 *
405 * @since 1.0.0
406 *
407 * @param WP_REST_Request $request Request object
408 * @return WP_REST_Response Response object
409 */
410 public function get_post_types(WP_REST_Request $request): WP_REST_Response {
411 $args = [
412 'public' => true,
413 ];
414
415 $post_types = get_post_types($args, "objects");
416 $post_types = array_filter($post_types, 'is_post_type_viewable');
417
418 $data = [];
419 foreach ($post_types as $post_type) {
420 $data[] = [
421 'slug' => $post_type->name,
422 'name' => $post_type->label,
423 'singular_name' => $post_type->labels->singular_name
424 ];
425 }
426
427 return new WP_REST_Response([
428 'success' => true,
429 'data' => $data
430 ], 200);
431 }
432
433 /**
434 * Regenerate API Key
435 *
436 * @since 1.0.0
437 *
438 * @param WP_REST_Request $request Request object
439 * @return WP_REST_Response Response object
440 */
441 public function regenerate_api_key(WP_REST_Request $request): WP_REST_Response {
442 $settings = get_option($this->option_name, []);
443 $old_key = $settings['api_key'] ?? null;
444
445 $new_key = $this->generate_api_key();
446
447 if (!is_array($settings)) {
448 $settings = [];
449 }
450
451 $settings['api_key'] = $new_key;
452 update_option($this->option_name, $settings);
453
454 // Update key files (create new, delete old)
455 $this->manage_key_file($new_key, $old_key);
456
457 return new WP_REST_Response([
458 'success' => true,
459 'key' => $new_key,
460 'message' => __('API Key regenerated successfully', 'thinkrank')
461 ], 200);
462 }
463
464 /**
465 * Manage API Key File (Create new, delete old)
466 *
467 * @param string $new_key New API Key
468 * @param string|null $old_key Old API Key to delete
469 * @return void
470 */
471 private function manage_key_file(string $new_key, ?string $old_key = null): void {
472 global $wp_filesystem;
473 if (!function_exists('WP_Filesystem')) {
474 require_once ABSPATH . 'wp-admin/includes/file.php';
475 }
476 WP_Filesystem();
477
478 if (!$wp_filesystem) {
479 return;
480 }
481
482 // Defense-in-depth: the key becomes a filename under ABSPATH, so never
483 // touch the filesystem with anything that isn't a plain hex token. Guards
484 // against a traversal payload (e.g. ../../ads) reaching put_contents/delete.
485 $is_valid_key = static function (string $key): bool {
486 return (bool) preg_match('/^[a-f0-9]{8,64}$/', $key);
487 };
488
489 // Create new file
490 if (!empty($new_key) && $is_valid_key($new_key)) {
491 $file_path = ABSPATH . $new_key . '.txt';
492 if ($wp_filesystem->is_writable(ABSPATH)) {
493 $wp_filesystem->put_contents($file_path, $new_key, FS_CHMOD_FILE);
494 }
495 }
496
497 // Delete old file
498 if (!empty($old_key) && $old_key !== $new_key && $is_valid_key($old_key)) {
499 $old_file_path = ABSPATH . $old_key . '.txt';
500 if ($wp_filesystem->exists($old_file_path)) {
501 $wp_filesystem->delete($old_file_path);
502 }
503 }
504 }
505
506 /**
507 * Generate a random API key (32 chars hex)
508 *
509 * @return string
510 */
511 private function generate_api_key(): string {
512 try {
513 return bin2hex(random_bytes(16));
514 } catch (\Exception $e) {
515 // Fallback if random_bytes fails
516 return md5(uniqid((string) wp_rand(), true));
517 }
518 }
519
520 /**
521 * Submit URLs manually
522 *
523 * @since 1.1.0
524 *
525 * @param WP_REST_Request $request Request object
526 * @return WP_REST_Response Response object
527 */
528 public function submit_urls_to_api(WP_REST_Request $request): WP_REST_Response {
529 $urls_param = $request->get_param('urls');
530 $urls = array_filter(array_map('trim', explode("\n", $urls_param)));
531
532 if (empty($urls)) {
533 return new WP_REST_Response([
534 'success' => false,
535 'message' => __('No valid URLs provided', 'thinkrank')
536 ], 400);
537 }
538
539 // Limit to 100 for manual submission safety
540 if (count($urls) > 100) {
541 $urls = array_slice($urls, 0, 100);
542 }
543
544 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
545 $result = $manager->submit_urls($urls);
546
547 // Report the count the manager actually submitted (after same-host
548 // filtering and its cap), not the raw input size — otherwise a mix of
549 // foreign URLs would overstate how many were sent to IndexNow.
550 return new WP_REST_Response([
551 'success' => $result['success'],
552 'message' => $result['message'],
553 'count' => (int) ($result['submitted_count'] ?? 0)
554 ], 200);
555 }
556
557 /**
558 * Verify the advertised IndexNow key file is reachable and returns the key.
559 *
560 * Runs a one-shot loopback fetch of keyLocation so an unreachable-key
561 * configuration (read-only root + Plain permalinks, a CDN edge rule, etc.)
562 * surfaces on the settings screen instead of as a silent 403 at first
563 * submission (see #247).
564 *
565 * @since 1.28.0
566 *
567 * @param WP_REST_Request $request Request object
568 * @return WP_REST_Response Response object
569 */
570 public function verify_key(WP_REST_Request $request): WP_REST_Response {
571 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
572
573 return new WP_REST_Response([
574 'success' => true,
575 'data' => $manager->verify_key_reachable(),
576 ], 200);
577 }
578
579 /**
580 * Get submission history
581 *
582 * @since 1.1.0
583 *
584 * @param WP_REST_Request $request Request object
585 * @return WP_REST_Response Response object
586 */
587 public function get_submission_history(WP_REST_Request $request): WP_REST_Response {
588 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
589
590 // Prefer server-side pagination (page/per_page). Fall back to the legacy
591 // limit param for older callers.
592 $page = (int) ($request->get_param('page') ?: 0);
593 $per_page = (int) ($request->get_param('per_page') ?: 0);
594
595 if ($page > 0 || $per_page > 0) {
596 $result = $manager->get_history_page($page > 0 ? $page : 1, $per_page > 0 ? $per_page : 10);
597 return new WP_REST_Response([
598 'success' => true,
599 'data' => $result['items'],
600 'pagination' => [
601 'total' => $result['total'],
602 'page' => $result['page'],
603 'per_page' => $result['per_page'],
604 'total_pages' => (int) ceil($result['total'] / $result['per_page']),
605 ],
606 ], 200);
607 }
608
609 $limit = $request->get_param('limit') ?: -1;
610 $history = $manager->get_history((int) $limit);
611
612 return new WP_REST_Response([
613 'success' => true,
614 'data' => $history
615 ], 200);
616 }
617
618 /**
619 * Clear submission history
620 *
621 * @since 1.1.0
622 *
623 * @param WP_REST_Request $request Request object
624 * @return WP_REST_Response Response object
625 */
626 public function clear_submission_history(WP_REST_Request $request): WP_REST_Response {
627 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
628 $result = $manager->clear_history();
629
630 if ($result) {
631 return new WP_REST_Response([
632 'success' => true,
633 'message' => __('History cleared successfully', 'thinkrank')
634 ], 200);
635 }
636
637 return new WP_REST_Response([
638 'success' => false,
639 'message' => __('Failed to clear history', 'thinkrank')
640 ], 500);
641 }
642
643 /**
644 * Check read permissions
645 *
646 * @since 1.0.0
647 *
648 * @return bool Permission status
649 */
650 public function check_read_permissions(): bool {
651 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_instant_indexing');
652 }
653
654 /**
655 * Check manage permissions
656 *
657 * @since 1.0.0
658 *
659 * @return bool Permission status
660 */
661 public function check_manage_permissions(): bool {
662 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_instant_indexing');
663 }
664
665 /**
666 * Get arguments for settings endpoints
667 *
668 * @since 1.0.0
669 *
670 * @return array Arguments array
671 */
672 private function get_settings_args(): array {
673 return [
674 'auto_submit_post_types' => [
675 'required' => false,
676 'type' => 'array',
677 'items' => [
678 'type' => 'string'
679 ],
680 'description' => 'List of post types to auto-submit'
681 ],
682 'api_key' => [
683 'required' => false,
684 'type' => 'string',
685 'description' => 'IndexNow API Key'
686 ],
687 'enabled' => [
688 'required' => false,
689 'type' => 'boolean',
690 'description' => 'Enable or disable Instant Indexing'
691 ]
692 ];
693 }
694 }
695