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

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