PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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.1.1, at includes/api/class-instant-indexing-endpoint.php

687 lines 21.5 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 // Sanitize Post Types
354 $post_types = isset($params['auto_submit_post_types']) ? (array) $params['auto_submit_post_types'] : [];
355 $sanitized_post_types = array_map('sanitize_text_field', $post_types);
356
357 // We generally don't let user update API Key directly via update_settings,
358 // they should use regenerate, but if we need to support manual entry:
359 $current_settings = get_option($this->option_name, []);
360 $new_settings = array_merge($current_settings, [
361 'auto_submit_post_types' => $sanitized_post_types
362 ]);
363
364 // Save enabled state
365 if (isset($params['enabled'])) {
366 $new_settings['enabled'] = rest_sanitize_boolean($params['enabled']);
367 }
368
369 // If API key is provided and different (rare case), sanitize and validate
370 // it. The key is used to build a file path under ABSPATH, so it must be a
371 // plain hex token — reject anything else (e.g. path-traversal sequences).
372 if (isset($params['api_key'])) {
373 $candidate_key = sanitize_text_field($params['api_key']);
374 if (!preg_match('/^[a-f0-9]{8,64}$/', $candidate_key)) {
375 return new WP_REST_Response([
376 'success' => false,
377 'message' => __('Invalid API key format. It must be 8–64 hexadecimal characters.', 'thinkrank'),
378 ], 400);
379 }
380 $new_settings['api_key'] = $candidate_key;
381 }
382
383 update_option($this->option_name, $new_settings);
384
385 return new WP_REST_Response([
386 'success' => true,
387 'message' => __('Settings updated successfully', 'thinkrank'),
388 'data' => $new_settings
389 ], 200);
390 }
391
392 /**
393 * Get viewable post types
394 *
395 * Uses custom args as per requirements.
396 *
397 * @since 1.0.0
398 *
399 * @param WP_REST_Request $request Request object
400 * @return WP_REST_Response Response object
401 */
402 public function get_post_types(WP_REST_Request $request): WP_REST_Response {
403 $args = [
404 'public' => true,
405 ];
406
407 $post_types = get_post_types($args, "objects");
408 $post_types = array_filter($post_types, 'is_post_type_viewable');
409
410 $data = [];
411 foreach ($post_types as $post_type) {
412 $data[] = [
413 'slug' => $post_type->name,
414 'name' => $post_type->label,
415 'singular_name' => $post_type->labels->singular_name
416 ];
417 }
418
419 return new WP_REST_Response([
420 'success' => true,
421 'data' => $data
422 ], 200);
423 }
424
425 /**
426 * Regenerate API Key
427 *
428 * @since 1.0.0
429 *
430 * @param WP_REST_Request $request Request object
431 * @return WP_REST_Response Response object
432 */
433 public function regenerate_api_key(WP_REST_Request $request): WP_REST_Response {
434 $settings = get_option($this->option_name, []);
435 $old_key = $settings['api_key'] ?? null;
436
437 $new_key = $this->generate_api_key();
438
439 if (!is_array($settings)) {
440 $settings = [];
441 }
442
443 $settings['api_key'] = $new_key;
444 update_option($this->option_name, $settings);
445
446 // Update key files (create new, delete old)
447 $this->manage_key_file($new_key, $old_key);
448
449 return new WP_REST_Response([
450 'success' => true,
451 'key' => $new_key,
452 'message' => __('API Key regenerated successfully', 'thinkrank')
453 ], 200);
454 }
455
456 /**
457 * Manage API Key File (Create new, delete old)
458 *
459 * @param string $new_key New API Key
460 * @param string|null $old_key Old API Key to delete
461 * @return void
462 */
463 private function manage_key_file(string $new_key, ?string $old_key = null): void {
464 global $wp_filesystem;
465 if (!function_exists('WP_Filesystem')) {
466 require_once ABSPATH . 'wp-admin/includes/file.php';
467 }
468 WP_Filesystem();
469
470 if (!$wp_filesystem) {
471 return;
472 }
473
474 // Defense-in-depth: the key becomes a filename under ABSPATH, so never
475 // touch the filesystem with anything that isn't a plain hex token. Guards
476 // against a traversal payload (e.g. ../../ads) reaching put_contents/delete.
477 $is_valid_key = static function (string $key): bool {
478 return (bool) preg_match('/^[a-f0-9]{8,64}$/', $key);
479 };
480
481 // Create new file
482 if (!empty($new_key) && $is_valid_key($new_key)) {
483 $file_path = ABSPATH . $new_key . '.txt';
484 if ($wp_filesystem->is_writable(ABSPATH)) {
485 $wp_filesystem->put_contents($file_path, $new_key, FS_CHMOD_FILE);
486 }
487 }
488
489 // Delete old file
490 if (!empty($old_key) && $old_key !== $new_key && $is_valid_key($old_key)) {
491 $old_file_path = ABSPATH . $old_key . '.txt';
492 if ($wp_filesystem->exists($old_file_path)) {
493 $wp_filesystem->delete($old_file_path);
494 }
495 }
496 }
497
498 /**
499 * Generate a random API key (32 chars hex)
500 *
501 * @return string
502 */
503 private function generate_api_key(): string {
504 try {
505 return bin2hex(random_bytes(16));
506 } catch (\Exception $e) {
507 // Fallback if random_bytes fails
508 return md5(uniqid((string) wp_rand(), true));
509 }
510 }
511
512 /**
513 * Submit URLs manually
514 *
515 * @since 1.1.0
516 *
517 * @param WP_REST_Request $request Request object
518 * @return WP_REST_Response Response object
519 */
520 public function submit_urls_to_api(WP_REST_Request $request): WP_REST_Response {
521 $urls_param = $request->get_param('urls');
522 $urls = array_filter(array_map('trim', explode("\n", $urls_param)));
523
524 if (empty($urls)) {
525 return new WP_REST_Response([
526 'success' => false,
527 'message' => __('No valid URLs provided', 'thinkrank')
528 ], 400);
529 }
530
531 // Limit to 100 for manual submission safety
532 if (count($urls) > 100) {
533 $urls = array_slice($urls, 0, 100);
534 }
535
536 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
537 $result = $manager->submit_urls($urls);
538
539 // Report the count the manager actually submitted (after same-host
540 // filtering and its cap), not the raw input size — otherwise a mix of
541 // foreign URLs would overstate how many were sent to IndexNow.
542 return new WP_REST_Response([
543 'success' => $result['success'],
544 'message' => $result['message'],
545 'count' => (int) ($result['submitted_count'] ?? 0)
546 ], 200);
547 }
548
549 /**
550 * Verify the advertised IndexNow key file is reachable and returns the key.
551 *
552 * Runs a one-shot loopback fetch of keyLocation so an unreachable-key
553 * configuration (read-only root + Plain permalinks, a CDN edge rule, etc.)
554 * surfaces on the settings screen instead of as a silent 403 at first
555 * submission (see #247).
556 *
557 * @since 1.28.0
558 *
559 * @param WP_REST_Request $request Request object
560 * @return WP_REST_Response Response object
561 */
562 public function verify_key(WP_REST_Request $request): WP_REST_Response {
563 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
564
565 return new WP_REST_Response([
566 'success' => true,
567 'data' => $manager->verify_key_reachable(),
568 ], 200);
569 }
570
571 /**
572 * Get submission history
573 *
574 * @since 1.1.0
575 *
576 * @param WP_REST_Request $request Request object
577 * @return WP_REST_Response Response object
578 */
579 public function get_submission_history(WP_REST_Request $request): WP_REST_Response {
580 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
581
582 // Prefer server-side pagination (page/per_page). Fall back to the legacy
583 // limit param for older callers.
584 $page = (int) ($request->get_param('page') ?: 0);
585 $per_page = (int) ($request->get_param('per_page') ?: 0);
586
587 if ($page > 0 || $per_page > 0) {
588 $result = $manager->get_history_page($page > 0 ? $page : 1, $per_page > 0 ? $per_page : 10);
589 return new WP_REST_Response([
590 'success' => true,
591 'data' => $result['items'],
592 'pagination' => [
593 'total' => $result['total'],
594 'page' => $result['page'],
595 'per_page' => $result['per_page'],
596 'total_pages' => (int) ceil($result['total'] / $result['per_page']),
597 ],
598 ], 200);
599 }
600
601 $limit = $request->get_param('limit') ?: -1;
602 $history = $manager->get_history((int) $limit);
603
604 return new WP_REST_Response([
605 'success' => true,
606 'data' => $history
607 ], 200);
608 }
609
610 /**
611 * Clear submission history
612 *
613 * @since 1.1.0
614 *
615 * @param WP_REST_Request $request Request object
616 * @return WP_REST_Response Response object
617 */
618 public function clear_submission_history(WP_REST_Request $request): WP_REST_Response {
619 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
620 $result = $manager->clear_history();
621
622 if ($result) {
623 return new WP_REST_Response([
624 'success' => true,
625 'message' => __('History cleared successfully', 'thinkrank')
626 ], 200);
627 }
628
629 return new WP_REST_Response([
630 'success' => false,
631 'message' => __('Failed to clear history', 'thinkrank')
632 ], 500);
633 }
634
635 /**
636 * Check read permissions
637 *
638 * @since 1.0.0
639 *
640 * @return bool Permission status
641 */
642 public function check_read_permissions(): bool {
643 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_instant_indexing');
644 }
645
646 /**
647 * Check manage permissions
648 *
649 * @since 1.0.0
650 *
651 * @return bool Permission status
652 */
653 public function check_manage_permissions(): bool {
654 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_instant_indexing');
655 }
656
657 /**
658 * Get arguments for settings endpoints
659 *
660 * @since 1.0.0
661 *
662 * @return array Arguments array
663 */
664 private function get_settings_args(): array {
665 return [
666 'auto_submit_post_types' => [
667 'required' => false,
668 'type' => 'array',
669 'items' => [
670 'type' => 'string'
671 ],
672 'description' => 'List of post types to auto-submit'
673 ],
674 'api_key' => [
675 'required' => false,
676 'type' => 'string',
677 'description' => 'IndexNow API Key'
678 ],
679 'enabled' => [
680 'required' => false,
681 'type' => 'boolean',
682 'description' => 'Enable or disable Instant Indexing'
683 ]
684 ];
685 }
686 }
687