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

518 lines 15.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 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 // Get submission history
128 register_rest_route(
129 $this->namespace,
130 '/' . $this->rest_base . '/history',
131 [
132 [
133 'methods' => 'GET',
134 'callback' => [$this, 'get_submission_history'],
135 'permission_callback' => [$this, 'check_read_permissions'],
136 'args' => [
137 'limit' => [
138 'required' => false,
139 'type' => 'integer',
140 'default' => -1
141 ]
142 ]
143 ],
144 [
145 'methods' => 'DELETE',
146 'callback' => [$this, 'clear_submission_history'],
147 'permission_callback' => [$this, 'check_manage_permissions']
148 ]
149 ]
150 );
151 }
152
153 /**
154 * Get settings
155 *
156 * @since 1.0.0
157 *
158 * @param WP_REST_Request $request Request object
159 * @return WP_REST_Response Response object
160 */
161 public function get_settings(WP_REST_Request $request): WP_REST_Response {
162 $settings = get_option($this->option_name, []);
163
164 $defaults = [
165 'enabled' => false,
166 'auto_submit_post_types' => ['post', 'page'],
167 'api_key' => ''
168 ];
169
170 $settings = wp_parse_args($settings, $defaults);
171
172 // Ensure api_key is always present
173 if (empty($settings['api_key'])) {
174 $settings['api_key'] = $this->generate_api_key();
175 $this->manage_key_file($settings['api_key']);
176 update_option($this->option_name, $settings);
177 } else {
178 // Verify file exists for existing key, create if missing
179 $file_path = ABSPATH . $settings['api_key'] . '.txt';
180 if (!file_exists($file_path)) {
181 $this->manage_key_file($settings['api_key']);
182 }
183 }
184
185 return new WP_REST_Response([
186 'success' => true,
187 'data' => $settings
188 ], 200);
189 }
190
191 /**
192 * Update settings
193 *
194 * @since 1.0.0
195 *
196 * @param WP_REST_Request $request Request object
197 * @return WP_REST_Response|WP_Error Response object or error
198 */
199 public function update_settings(WP_REST_Request $request) {
200 $params = $request->get_json_params();
201
202 if (empty($params)) {
203 $params = $request->get_params(); // Fallback if content-type is not JSON
204 }
205
206 // Sanitize Post Types
207 $post_types = isset($params['auto_submit_post_types']) ? (array) $params['auto_submit_post_types'] : [];
208 $sanitized_post_types = array_map('sanitize_text_field', $post_types);
209
210 // We generally don't let user update API Key directly via update_settings,
211 // they should use regenerate, but if we need to support manual entry:
212 $current_settings = get_option($this->option_name, []);
213 $new_settings = array_merge($current_settings, [
214 'auto_submit_post_types' => $sanitized_post_types
215 ]);
216
217 // Save enabled state
218 if (isset($params['enabled'])) {
219 $new_settings['enabled'] = rest_sanitize_boolean($params['enabled']);
220 }
221
222 // If API key is provided and different (rare case), sanitize and validate
223 // it. The key is used to build a file path under ABSPATH, so it must be a
224 // plain hex token — reject anything else (e.g. path-traversal sequences).
225 if (isset($params['api_key'])) {
226 $candidate_key = sanitize_text_field($params['api_key']);
227 if (!preg_match('/^[a-f0-9]{8,64}$/', $candidate_key)) {
228 return new WP_REST_Response([
229 'success' => false,
230 'message' => __('Invalid API key format. It must be 8–64 hexadecimal characters.', 'thinkrank'),
231 ], 400);
232 }
233 $new_settings['api_key'] = $candidate_key;
234 }
235
236 update_option($this->option_name, $new_settings);
237
238 return new WP_REST_Response([
239 'success' => true,
240 'message' => __('Settings updated successfully', 'thinkrank'),
241 'data' => $new_settings
242 ], 200);
243 }
244
245 /**
246 * Get viewable post types
247 *
248 * Uses custom args as per requirements.
249 *
250 * @since 1.0.0
251 *
252 * @param WP_REST_Request $request Request object
253 * @return WP_REST_Response Response object
254 */
255 public function get_post_types(WP_REST_Request $request): WP_REST_Response {
256 $args = [
257 'public' => true,
258 ];
259
260 $post_types = get_post_types($args, "objects");
261 $post_types = array_filter($post_types, 'is_post_type_viewable');
262
263 $data = [];
264 foreach ($post_types as $post_type) {
265 $data[] = [
266 'slug' => $post_type->name,
267 'name' => $post_type->label,
268 'singular_name' => $post_type->labels->singular_name
269 ];
270 }
271
272 return new WP_REST_Response([
273 'success' => true,
274 'data' => $data
275 ], 200);
276 }
277
278 /**
279 * Regenerate API Key
280 *
281 * @since 1.0.0
282 *
283 * @param WP_REST_Request $request Request object
284 * @return WP_REST_Response Response object
285 */
286 public function regenerate_api_key(WP_REST_Request $request): WP_REST_Response {
287 $settings = get_option($this->option_name, []);
288 $old_key = $settings['api_key'] ?? null;
289
290 $new_key = $this->generate_api_key();
291
292 if (!is_array($settings)) {
293 $settings = [];
294 }
295
296 $settings['api_key'] = $new_key;
297 update_option($this->option_name, $settings);
298
299 // Update key files (create new, delete old)
300 $this->manage_key_file($new_key, $old_key);
301
302 return new WP_REST_Response([
303 'success' => true,
304 'key' => $new_key,
305 'message' => __('API Key regenerated successfully', 'thinkrank')
306 ], 200);
307 }
308
309 /**
310 * Manage API Key File (Create new, delete old)
311 *
312 * @param string $new_key New API Key
313 * @param string|null $old_key Old API Key to delete
314 * @return void
315 */
316 private function manage_key_file(string $new_key, ?string $old_key = null): void {
317 global $wp_filesystem;
318 if (!function_exists('WP_Filesystem')) {
319 require_once ABSPATH . 'wp-admin/includes/file.php';
320 }
321 WP_Filesystem();
322
323 if (!$wp_filesystem) {
324 return;
325 }
326
327 // Defense-in-depth: the key becomes a filename under ABSPATH, so never
328 // touch the filesystem with anything that isn't a plain hex token. Guards
329 // against a traversal payload (e.g. ../../ads) reaching put_contents/delete.
330 $is_valid_key = static function (string $key): bool {
331 return (bool) preg_match('/^[a-f0-9]{8,64}$/', $key);
332 };
333
334 // Create new file
335 if (!empty($new_key) && $is_valid_key($new_key)) {
336 $file_path = ABSPATH . $new_key . '.txt';
337 if ($wp_filesystem->is_writable(ABSPATH)) {
338 $wp_filesystem->put_contents($file_path, $new_key, FS_CHMOD_FILE);
339 }
340 }
341
342 // Delete old file
343 if (!empty($old_key) && $old_key !== $new_key && $is_valid_key($old_key)) {
344 $old_file_path = ABSPATH . $old_key . '.txt';
345 if ($wp_filesystem->exists($old_file_path)) {
346 $wp_filesystem->delete($old_file_path);
347 }
348 }
349 }
350
351 /**
352 * Generate a random API key (32 chars hex)
353 *
354 * @return string
355 */
356 private function generate_api_key(): string {
357 try {
358 return bin2hex(random_bytes(16));
359 } catch (\Exception $e) {
360 // Fallback if random_bytes fails
361 return md5(uniqid((string) wp_rand(), true));
362 }
363 }
364
365 /**
366 * Submit URLs manually
367 *
368 * @since 1.1.0
369 *
370 * @param WP_REST_Request $request Request object
371 * @return WP_REST_Response Response object
372 */
373 public function submit_urls_to_api(WP_REST_Request $request): WP_REST_Response {
374 $urls_param = $request->get_param('urls');
375 $urls = array_filter(array_map('trim', explode("\n", $urls_param)));
376
377 if (empty($urls)) {
378 return new WP_REST_Response([
379 'success' => false,
380 'message' => __('No valid URLs provided', 'thinkrank')
381 ], 400);
382 }
383
384 // Limit to 100 for manual submission safety
385 if (count($urls) > 100) {
386 $urls = array_slice($urls, 0, 100);
387 }
388
389 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
390 $result = $manager->submit_urls($urls);
391
392 // Report the count the manager actually submitted (after same-host
393 // filtering and its cap), not the raw input size — otherwise a mix of
394 // foreign URLs would overstate how many were sent to IndexNow.
395 return new WP_REST_Response([
396 'success' => $result['success'],
397 'message' => $result['message'],
398 'count' => (int) ($result['submitted_count'] ?? 0)
399 ], 200);
400 }
401
402 /**
403 * Get submission history
404 *
405 * @since 1.1.0
406 *
407 * @param WP_REST_Request $request Request object
408 * @return WP_REST_Response Response object
409 */
410 public function get_submission_history(WP_REST_Request $request): WP_REST_Response {
411 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
412
413 // Prefer server-side pagination (page/per_page). Fall back to the legacy
414 // limit param for older callers.
415 $page = (int) ($request->get_param('page') ?: 0);
416 $per_page = (int) ($request->get_param('per_page') ?: 0);
417
418 if ($page > 0 || $per_page > 0) {
419 $result = $manager->get_history_page($page > 0 ? $page : 1, $per_page > 0 ? $per_page : 10);
420 return new WP_REST_Response([
421 'success' => true,
422 'data' => $result['items'],
423 'pagination' => [
424 'total' => $result['total'],
425 'page' => $result['page'],
426 'per_page' => $result['per_page'],
427 'total_pages' => (int) ceil($result['total'] / $result['per_page']),
428 ],
429 ], 200);
430 }
431
432 $limit = $request->get_param('limit') ?: -1;
433 $history = $manager->get_history((int) $limit);
434
435 return new WP_REST_Response([
436 'success' => true,
437 'data' => $history
438 ], 200);
439 }
440
441 /**
442 * Clear submission history
443 *
444 * @since 1.1.0
445 *
446 * @param WP_REST_Request $request Request object
447 * @return WP_REST_Response Response object
448 */
449 public function clear_submission_history(WP_REST_Request $request): WP_REST_Response {
450 $manager = new \ThinkRank\SEO\Instant_Indexing_Manager();
451 $result = $manager->clear_history();
452
453 if ($result) {
454 return new WP_REST_Response([
455 'success' => true,
456 'message' => __('History cleared successfully', 'thinkrank')
457 ], 200);
458 }
459
460 return new WP_REST_Response([
461 'success' => false,
462 'message' => __('Failed to clear history', 'thinkrank')
463 ], 500);
464 }
465
466 /**
467 * Check read permissions
468 *
469 * @since 1.0.0
470 *
471 * @return bool Permission status
472 */
473 public function check_read_permissions(): bool {
474 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_instant_indexing');
475 }
476
477 /**
478 * Check manage permissions
479 *
480 * @since 1.0.0
481 *
482 * @return bool Permission status
483 */
484 public function check_manage_permissions(): bool {
485 return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_instant_indexing');
486 }
487
488 /**
489 * Get arguments for settings endpoints
490 *
491 * @since 1.0.0
492 *
493 * @return array Arguments array
494 */
495 private function get_settings_args(): array {
496 return [
497 'auto_submit_post_types' => [
498 'required' => false,
499 'type' => 'array',
500 'items' => [
501 'type' => 'string'
502 ],
503 'description' => 'List of post types to auto-submit'
504 ],
505 'api_key' => [
506 'required' => false,
507 'type' => 'string',
508 'description' => 'IndexNow API Key'
509 ],
510 'enabled' => [
511 'required' => false,
512 'type' => 'boolean',
513 'description' => 'Enable or disable Instant Indexing'
514 ]
515 ];
516 }
517 }
518