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

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