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

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