PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.2
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.2
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 / seo / class-instant-indexing-manager.php

class-instant-indexing-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.0.2, at includes/seo/class-instant-indexing-manager.php

817 lines 30.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Instant Indexing Manager Class
4 *
5 * Handles automated submission of URLs to IndexNow API.
6 *
7 * @package ThinkRank
8 * @subpackage SEO
9 * @since 1.1.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\SEO;
15
16 // Prevent direct access.
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20
21 /**
22 * Instant Indexing Manager Class
23 *
24 * Auto-submits URLs to IndexNow when content is updated.
25 *
26 * @since 1.1.0
27 */
28 class Instant_Indexing_Manager {
29 /**
30 * Settings option name
31 *
32 * @var string
33 */
34 private $option_name = 'thinkrank_instant_indexing_settings';
35
36 /**
37 * IndexNow API Endpoint
38 *
39 * @var string
40 */
41 private $api_endpoint = 'https://api.indexnow.org/indexnow';
42
43 /**
44 * Cron hook used to submit URLs to IndexNow out-of-band.
45 *
46 * @var string
47 */
48 private const CRON_SUBMIT_HOOK = 'thinkrank_instant_indexing_submit';
49
50 /**
51 * Maximum URLs accepted per submission across every path (manual REST, bulk,
52 * MCP). Keeps the paths consistent; larger sets are truncated to this cap.
53 */
54 public const MAX_URLS_PER_SUBMISSION = 100;
55
56 /**
57 * Initialize the component
58 *
59 * @since 1.1.0
60 * @return void
61 */
62 public function init(): void {
63 add_action('transition_post_status', [$this, 'handle_post_transition'], 10, 3);
64 add_action('delete_post', [$this, 'handle_post_deletion'], 10, 2);
65
66 // Serve the IndexNow key file from PHP when no physical file exists.
67 // The key is normally written to the WordPress root, but on managed and
68 // hardened hosting that root is read-only, the write was skipped in
69 // silence, and every submission then came back 403 Forbidden — the one
70 // status IndexNow returns when it cannot read the key at the advertised
71 // keyLocation. Answering the request directly removes the filesystem
72 // from the critical path entirely. A real file on disk still wins: the
73 // web server serves it and this never runs.
74 add_action('parse_request', [$this, 'maybe_serve_key_file']);
75
76 // Automatic submissions run out-of-band via WP-Cron so the editor's
77 // save/publish/delete request never blocks on the IndexNow HTTP call.
78 // Registered unconditionally (WP-Cron runs outside the admin context).
79 add_action(self::CRON_SUBMIT_HOOK, [$this, 'submit_urls_cron'], 10, 1);
80
81 if (is_admin()) {
82 $this->register_bulk_actions_hook();
83 $this->register_handler_hooks();
84 add_action('admin_notices', [$this, 'bulk_action_admin_notice']);
85
86 // Row actions
87 add_filter('post_row_actions', [$this, 'add_row_action_link'], 10, 2);
88 add_filter('page_row_actions', [$this, 'add_row_action_link'], 10, 2);
89 add_action('admin_action_thinkrank_instant_index_single', [$this, 'handle_single_action_submit']);
90 }
91 }
92
93 /**
94 * Serve `<key>.txt` at the site root when no physical file is present.
95 *
96 * IndexNow verifies ownership by fetching the key from `keyLocation` and
97 * comparing it to the key in the payload; anything else is a 403. Writing
98 * that file to ABSPATH fails on read-only roots, so this answers the
99 * request from PHP instead. Runs on `parse_request` (before the main query)
100 * because a missing `.txt` is routed to WordPress by the standard rewrite,
101 * and only matches the site's own current key — never an arbitrary path.
102 *
103 * @since 1.27.0
104 * @param \WP $wp Current WordPress environment instance.
105 * @return void
106 */
107 public function maybe_serve_key_file($wp): void {
108 if (is_admin()) {
109 return;
110 }
111
112 $settings = get_option($this->option_name, []);
113 if (empty($settings['enabled'])) {
114 return;
115 }
116
117 $api_key = (string) ($settings['api_key'] ?? '');
118 // The key is also a filename elsewhere, so it is always a plain hex
119 // token; refuse to match on anything else rather than compare loosely.
120 if (!preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
121 return;
122 }
123
124 $path = (string) wp_parse_url(
125 isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '',
126 PHP_URL_PATH
127 );
128
129 // Compare against the path of the advertised keyLocation, so a site in
130 // a subdirectory resolves exactly as it is announced to IndexNow.
131 $expected = (string) wp_parse_url(self::key_location($api_key), PHP_URL_PATH);
132 if ($expected === '' || untrailingslashit($path) !== untrailingslashit($expected)) {
133 return;
134 }
135
136 status_header(200);
137 header('Content-Type: text/plain; charset=utf-8');
138 header('X-Robots-Tag: noindex');
139 echo esc_html($api_key);
140 exit;
141 }
142
143 /**
144 * The public URL IndexNow is told to fetch the key from.
145 *
146 * Single source of truth: the submission payload, the settings screen and
147 * the request matcher above all derive from this, so they cannot drift.
148 *
149 * @since 1.27.0
150 * @param string $api_key Verification key.
151 * @return string Absolute key file URL.
152 */
153 public static function key_location(string $api_key): string {
154 return home_url('/' . $api_key . '.txt');
155 }
156
157 /**
158 * Actively verify that the advertised keyLocation is reachable and returns
159 * the key — the general safety net for #247.
160 *
161 * IndexNow only ever answers 403 when it cannot read a matching key at
162 * keyLocation, and that failure is otherwise silent until the first
163 * submission. On a read-only root the physical `<key>.txt` is never written,
164 * and the `parse_request` fallback only fires when the request actually
165 * reaches WordPress — which it does not on an Apache-style host running
166 * Plain permalinks. This does a one-shot loopback fetch of the exact URL we
167 * announce to IndexNow so any unreachable-key configuration (that one
168 * included) is caught on the settings screen instead of at first submit.
169 *
170 * @since 1.28.0
171 * @return array{reachable:bool,code:int,url:string,reason:string}
172 */
173 public function verify_key_reachable(): array {
174 $settings = get_option($this->option_name, []);
175 $api_key = (string) ($settings['api_key'] ?? '');
176 $url = $api_key !== '' ? self::key_location($api_key) : '';
177
178 $result = [
179 'reachable' => false,
180 'code' => 0,
181 'url' => $url,
182 'reason' => '',
183 ];
184
185 if ($api_key === '' || !preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
186 $result['reason'] = __('No valid IndexNow key is set yet.', 'thinkrank');
187 return $result;
188 }
189
190 // Loopback fetch of our own key URL. sslverify is off because this is a
191 // self-check against this very site (a self-signed/local cert must not
192 // read as "unreachable"), mirroring how WP Site Health runs its loopback
193 // probes.
194 $response = wp_remote_get(
195 $url,
196 [
197 'timeout' => 7,
198 'sslverify' => false,
199 // translators: this is a diagnostic self-request user agent.
200 'user-agent' => 'ThinkRank-IndexNow-KeyCheck/1.0',
201 ]
202 );
203
204 if (is_wp_error($response)) {
205 $result['reason'] = sprintf(
206 /* translators: %s: HTTP error message. */
207 __('Could not reach the key file from this server (%s). Search engines may still reach it; open it in a browser to confirm.', 'thinkrank'),
208 $response->get_error_message()
209 );
210 return $result;
211 }
212
213 $result['code'] = (int) wp_remote_retrieve_response_code($response);
214 $body = trim((string) wp_remote_retrieve_body($response));
215
216 if ($result['code'] === 200 && hash_equals($api_key, $body)) {
217 $result['reachable'] = true;
218 return $result;
219 }
220
221 $result['reason'] = $this->key_unreachable_reason($result['code']);
222 return $result;
223 }
224
225 /**
226 * Build an actionable explanation when the key file is not reachable, naming
227 * the specific #247 combination (read-only root + Plain permalinks) so the
228 * user gets a fix instead of a silent, permanent 403.
229 *
230 * @since 1.28.0
231 * @param int $code HTTP status observed for the key URL (0 when none).
232 * @return string
233 */
234 private function key_unreachable_reason(int $code): string {
235 $root_writable = wp_is_writable(ABSPATH);
236 $pretty = (bool) get_option('permalink_structure');
237
238 // The exact #247 trap: the file can't be written (read-only root) AND
239 // the server only routes unknown paths to WordPress under pretty
240 // permalinks, so the PHP fallback never runs either.
241 if (!$root_writable && !$pretty) {
242 return __('Your site root is read-only (so the key file can’t be written) and permalinks are set to “Plain” (so ThinkRank can’t serve the key dynamically). Fix either one: set Settings → Permalinks to any option other than “Plain”, or make the site root writable.', 'thinkrank');
243 }
244
245 if ($code === 404) {
246 return __('The key file returned 404. If your site root is read-only, set Settings → Permalinks to any option other than “Plain” so ThinkRank can serve the key.', 'thinkrank');
247 }
248
249 return sprintf(
250 /* translators: %d: HTTP status code returned by the key URL. */
251 __('The key file could not be verified (HTTP %d). Open it in a browser — it should show the key and nothing else.', 'thinkrank'),
252 $code
253 );
254 }
255
256 /**
257 * Add Row Action Link
258 *
259 * @since 1.1.0
260 * @param array $actions Existing actions.
261 * @param \WP_Post $post Post object.
262 * @return array Modified actions.
263 */
264 public function add_row_action_link(array $actions, \WP_Post $post): array {
265 // Check if enabled and post type is supported
266 if (!$this->is_enabled() || !$this->is_post_type_supported($post->post_type)) {
267 return $actions;
268 }
269
270 // Check permissions
271 if (!current_user_can('edit_post', $post->ID)) {
272 return $actions;
273 }
274
275 $nonce = wp_create_nonce('thinkrank_instant_index_' . $post->ID);
276 $url = admin_url('admin.php?action=thinkrank_instant_index_single&post_id=' . $post->ID . '&nonce=' . $nonce);
277
278 $actions['thinkrank_instant_index'] = sprintf(
279 '<a href="%s">%s</a>',
280 esc_url($url),
281 esc_html__('ThinkRank: Instant Indexing Submit Page', 'thinkrank')
282 );
283
284 return $actions;
285 }
286
287 /**
288 * Handle Single Post Submission
289 *
290 * @since 1.1.0
291 * @return void
292 */
293 public function handle_single_action_submit(): void {
294 $post_id = isset($_GET['post_id']) ? (int) $_GET['post_id'] : 0;
295 $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
296
297 // Verify nonce
298 if (!wp_verify_nonce($nonce, 'thinkrank_instant_index_' . $post_id)) {
299 wp_die(esc_html__('Security check failed.', 'thinkrank'));
300 }
301
302 // Check permissions
303 if (!current_user_can('edit_post', $post_id)) {
304 wp_die(esc_html__('You do not have permission to edit this post.', 'thinkrank'));
305 }
306
307 $url = get_permalink($post_id);
308 $redirect_to = wp_get_referer() ?: admin_url('edit.php');
309
310 if ($url) {
311 $result = $this->submit_urls([$url]);
312
313 $redirect_to = add_query_arg([
314 'thinkrank_indexed_count' => 1,
315 'thinkrank_index_status' => $result['success'] ? 'success' : 'failed',
316 ], $redirect_to);
317 }
318
319 wp_safe_redirect($redirect_to);
320 exit;
321 }
322
323 /**
324 * Register Bulk Actions Hook
325 *
326 * @since 1.1.0
327 * @return void
328 */
329 public function register_bulk_actions_hook(): void {
330 add_filter('thinkrank_bulk_actions', [$this, 'add_bulk_action_item'], 10, 2);
331 }
332
333 /**
334 * Add Bulk Action Item to Dropdown
335 *
336 * @since 1.1.0
337 * @param array $actions Existing thinkrank actions.
338 * @param string $post_type Current post type.
339 * @return array Modified actions.
340 */
341 public function add_bulk_action_item(array $actions, string $post_type): array {
342 // Check if enabled and post type is supported
343 if (!$this->is_enabled() || !$this->is_post_type_supported($post_type)) {
344 return $actions;
345 }
346
347 $actions['thinkrank_instant_index'] = __('Instant Indexing: Submit Page', 'thinkrank');
348 return $actions;
349 }
350
351 /**
352 * Register handler hooks for bulk actions
353 *
354 * @since 1.1.0
355 * @return void
356 */
357 private function register_handler_hooks(): void {
358 $settings = get_option($this->option_name, []);
359 $supported_types = $settings['auto_submit_post_types'] ?? [];
360
361 foreach ($supported_types as $post_type) {
362 add_filter("handle_bulk_actions-edit-{$post_type}", [$this, 'handle_bulk_action_submit'], 10, 3);
363 }
364 }
365
366 /**
367 * Handle post status transitions (publish, update)
368 *
369 * @since 1.1.0
370 *
371 * @param string $new_status New post status.
372 * @param string $old_status Old post status.
373 * @param \WP_Post $post Post object.
374 * @return void
375 */
376 public function handle_post_transition(string $new_status, string $old_status, \WP_Post $post): void {
377
378 // Check if we should process this post
379 if (!$this->should_process_post($post)) {
380 return;
381 }
382
383 // We only care if the new status is publish (created or updated)
384 // OR if we are unpublishing (publish -> something else), we might want to update (though IndexNow is mostly for crawling new/updated content)
385 // For now, let's focus on published content.
386 if ($new_status === 'publish') {
387 $url = get_permalink($post->ID);
388
389 // Check for duplicate submission using short-lived cache (15 seconds)
390 if ($this->is_recently_submitted_cache($url)) {
391 return;
392 }
393
394 // Defer the outbound IndexNow call to WP-Cron so publishing doesn't
395 // block on a third-party HTTP request.
396 $this->schedule_url_submission([$url]);
397 }
398 }
399
400 /**
401 * Check if URL was recently submitted using transient cache
402 *
403 * @since 1.1.0
404 * @param string $url URL to check
405 * @return bool
406 */
407 private function is_recently_submitted_cache(string $url): bool {
408 $cache_key = 'thinkrank_indexing_' . md5($url);
409
410 if (get_transient($cache_key)) {
411 return true;
412 }
413
414 set_transient($cache_key, true, 15); // Cache for 15 seconds
415 return false;
416 }
417
418 /**
419 * Handle post deletion
420 *
421 * @since 1.1.0
422 *
423 * @param int $postid Post ID.
424 * @param \WP_Post $post Post object.
425 * @return void
426 */
427 public function handle_post_deletion(int $postid, \WP_Post $post): void {
428 // Check if we should process this post (even if it's being deleted, we might want to notify,
429 // though IndexNow 'submit' usually implies "please crawl this".
430 // IndexNow documentation says "notify... that a URL and its content has been added, updated, or deleted."
431 // So yes, we submit deleted URLs too if they were public.)
432
433 // For deletion, status might be 'trash' or 'delete', careful with checks.
434 // We only care if it was a supported post type.
435 if (!$this->is_post_type_supported($post->post_type)) {
436 return;
437 }
438
439 // If global setting disabled, abort
440 if (!$this->is_enabled()) {
441 return;
442 }
443
444 $url = get_permalink($postid);
445 if ($url) {
446 // Defer to WP-Cron so the delete request doesn't block on IndexNow.
447 $this->schedule_url_submission([$url]);
448 }
449 }
450
451 /**
452 * Check if a post should be processed
453 *
454 * @since 1.1.0
455 *
456 * @param \WP_Post $post Post object.
457 * @return bool True if should process, false otherwise.
458 */
459 private function should_process_post(\WP_Post $post): bool {
460 // 1. Check global enable switch
461 if (!$this->is_enabled()) {
462 return false;
463 }
464
465 // 2. Check if post type is supported
466 if (!$this->is_post_type_supported($post->post_type)) {
467 return false;
468 }
469
470 // 3. Check autosave/revision
471 if (wp_is_post_autosave($post) || wp_is_post_revision($post)) {
472 return false;
473 }
474
475 return true;
476 }
477
478 /**
479 * Check if feature is enabled globally
480 *
481 * @since 1.1.0
482 * @return bool
483 */
484 private function is_enabled(): bool {
485 $settings = get_option($this->option_name, []);
486 return isset($settings['enabled']) && $settings['enabled'];
487 }
488
489 /**
490 * Check if post type is in settings
491 *
492 * @since 1.1.0
493 * @param string $post_type The post type slug.
494 * @return bool
495 */
496 private function is_post_type_supported(string $post_type): bool {
497 $settings = get_option($this->option_name, []);
498 $supported_types = $settings['auto_submit_post_types'] ?? [];
499
500 return in_array($post_type, (array) $supported_types, true);
501 }
502
503 /**
504 * Submit URLs to IndexNow API
505 *
506 * @since 1.1.0
507 * @param array $urls List of URLs to submit.
508 * @return array Submission results.
509 */
510 public function submit_urls(array $urls): array {
511 if (empty($urls)) {
512 return ['success' => false, 'message' => 'No URLs provided', 'submitted_count' => 0];
513 }
514
515 $host = wp_parse_url(home_url(), PHP_URL_HOST);
516
517 // Only submit URLs on this site's host. IndexNow rejects a urlList whose
518 // entries don't match the declared host (HTTP 422), and the site's key
519 // must not be sent for foreign URLs — enforce it locally on every path.
520 $urls = array_values(array_filter($urls, static function ($u) use ($host) {
521 return strcasecmp((string) wp_parse_url((string) $u, PHP_URL_HOST), (string) $host) === 0;
522 }));
523 if (empty($urls)) {
524 return ['success' => false, 'message' => 'No URLs matched this site host', 'submitted_count' => 0];
525 }
526
527 // Enforce the shared per-submission cap so every path (manual, bulk, MCP)
528 // behaves consistently.
529 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
530 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
531 }
532
533 $settings = get_option($this->option_name, []);
534 $api_key = $settings['api_key'] ?? '';
535 if (empty($api_key)) {
536 return ['success' => false, 'message' => 'API Key missing', 'submitted_count' => 0];
537 }
538
539 $key_location = self::key_location($api_key);
540
541 $body = [
542 'host' => $host,
543 'key' => $api_key,
544 'keyLocation' => $key_location,
545 'urlList' => $urls
546 ];
547
548 $response = wp_remote_post($this->api_endpoint, [
549 'headers' => [
550 'Content-Type' => 'application/json; charset=utf-8'
551 ],
552 'body' => wp_json_encode($body),
553 'timeout' => 15,
554 'blocking' => true
555 ]);
556
557 // A network-layer failure (timeout, DNS, SSL) returns a WP_Error rather
558 // than an HTTP response — surface its message instead of logging an empty
559 // 0/'' row so the failure is diagnosable in the UI and history.
560 if (is_wp_error($response)) {
561 $status = 'failed';
562 $response_code = 0;
563 $response_message = $response->get_error_message();
564 } else {
565 $response_code = (int) wp_remote_retrieve_response_code($response);
566 $response_message = wp_remote_retrieve_response_message($response);
567 $status = ($response_code >= 200 && $response_code < 300) ? 'success' : 'failed';
568
569 // "403 Forbidden" is IndexNow's answer for exactly one problem —
570 // it could not read a matching key at keyLocation — but the raw
571 // status reads like a permissions error against the API and sent a
572 // customer hunting through the wrong settings for days. Say what it
573 // actually means, and name the URL to check.
574 if ($response_code === 403) {
575 $response_message = sprintf(
576 /* translators: %s: public URL of the IndexNow key file. */
577 __('Key file could not be verified. Search engines must be able to read your key at %s — open it in a browser: it should show the key and nothing else.', 'thinkrank'),
578 $key_location
579 );
580 }
581 }
582
583 // Log each URL
584 foreach ($urls as $url) {
585 $this->log_submission($url, $status, $response_code, $response_message);
586 }
587
588 return [
589 'success' => $status === 'success',
590 'code' => $response_code,
591 'message' => $response_message,
592 // The count actually sent to IndexNow after same-host filtering and
593 // the per-submission cap, so callers report the real number instead
594 // of the raw input size.
595 'submitted_count' => count($urls),
596 'submitted_urls' => $urls,
597 ];
598 }
599
600 /**
601 * Queue a set of URLs for out-of-band submission to IndexNow.
602 *
603 * Used by the automatic (transition_post_status / delete_post) hooks so the
604 * blocking HTTP call runs on a WP-Cron request instead of the editor's save
605 * request. WP-Cron collapses identical (hook + args) events scheduled close
606 * together, which further de-dupes rapid repeat saves of the same URL.
607 *
608 * @since 1.16.0
609 * @param array $urls List of URLs to submit.
610 * @return void
611 */
612 private function schedule_url_submission(array $urls): void {
613 if (empty($urls)) {
614 return;
615 }
616
617 if (!wp_next_scheduled(self::CRON_SUBMIT_HOOK, [$urls])) {
618 wp_schedule_single_event(time(), self::CRON_SUBMIT_HOOK, [$urls]);
619 }
620 }
621
622 /**
623 * WP-Cron handler: perform the deferred IndexNow submission.
624 *
625 * @since 1.16.0
626 * @param array $urls List of URLs to submit.
627 * @return void
628 */
629 public function submit_urls_cron(array $urls): void {
630 // Re-check the feature is still enabled in case it was turned off between
631 // scheduling and execution.
632 if (!$this->is_enabled()) {
633 return;
634 }
635
636 $this->submit_urls($urls);
637 }
638
639 /**
640 * Log submission to database
641 *
642 * @param string $url URL submitted
643 * @param string $status success/failed
644 * @param int|string $code Response code
645 * @param string $message Response message
646 */
647 private function log_submission(string $url, string $status, $code, string $message): void {
648 global $wpdb;
649 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
650
651 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Logging requires direct insert.
652 $wpdb->insert(
653 $table_name,
654 [
655 'url' => $url,
656 'status' => $status,
657 'response_code' => $code,
658 'response_message' => $message,
659 'created_at' => current_time('mysql')
660 ],
661 ['%s', '%s', '%d', '%s', '%s']
662 );
663 }
664
665 /**
666 * Get submission history
667 *
668 * @param int $limit Number of records to retrieve
669 * @return array
670 */
671 public function get_history(int $limit = -1): array {
672 global $wpdb;
673 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
674
675 if ($limit === -1) {
676 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
677 return $wpdb->get_results(
678 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
679 "SELECT * FROM `{$table_name}` ORDER BY created_at DESC",
680 ARRAY_A
681 );
682 }
683
684 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
685 return $wpdb->get_results(
686 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
687 $wpdb->prepare("SELECT * FROM `{$table_name}` ORDER BY created_at DESC LIMIT %d", $limit),
688 ARRAY_A
689 );
690 }
691
692 /**
693 * Get a bounded page of submission history plus the total row count, so the
694 * History tab paginates server-side instead of fetching the whole table.
695 *
696 * @param int $page 1-based page number.
697 * @param int $per_page Rows per page (clamped 1..100).
698 * @return array{items: array, total: int, page: int, per_page: int}
699 */
700 public function get_history_page(int $page = 1, int $per_page = 10): array {
701 global $wpdb;
702 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
703
704 $per_page = max(1, min(100, $per_page));
705 $page = max(1, $page);
706 $offset = ($page - 1) * $per_page;
707
708 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
709 $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$table_name}`");
710
711 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
712 $items = $wpdb->get_results(
713 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
714 $wpdb->prepare("SELECT * FROM `{$table_name}` ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset),
715 ARRAY_A
716 );
717
718 return [
719 'items' => $items ?: [],
720 'total' => $total,
721 'page' => $page,
722 'per_page' => $per_page,
723 ];
724 }
725
726 /**
727 * Clear submission history
728 *
729 * @since 1.1.0
730 * @return bool
731 */
732 public function clear_history(): bool {
733 global $wpdb;
734 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
735
736 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix, TRUNCATE requires direct query
737 $result = $wpdb->query("TRUNCATE TABLE `{$table_name}`");
738
739 return $result !== false;
740 }
741
742 /**
743 * Handle Bulk Action Submission
744 *
745 * @since 1.1.0
746 * @param string $redirect_to Redirect URL.
747 * @param string $action Action name.
748 * @param array $post_ids Selected post IDs.
749 * @return string Modified redirect URL.
750 */
751 public function handle_bulk_action_submit(string $redirect_to, string $action, array $post_ids): string {
752 if ($action !== 'thinkrank_instant_index') {
753 return $redirect_to;
754 }
755
756 $urls = [];
757 foreach ($post_ids as $post_id) {
758 $url = get_permalink($post_id);
759 if ($url) {
760 $urls[] = $url;
761 }
762 }
763
764 if (empty($urls)) {
765 return $redirect_to;
766 }
767
768 // Cap the set and defer the outbound call to WP-Cron so a large bulk
769 // selection doesn't block the admin request (parity with the auto path).
770 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
771 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
772 }
773 $count = count($urls);
774 $this->schedule_url_submission($urls);
775
776 // Add query args for admin notice
777 $redirect_to = add_query_arg([
778 'thinkrank_indexed_count' => $count,
779 'thinkrank_index_status' => 'scheduled',
780 ], $redirect_to);
781
782 return $redirect_to;
783 }
784
785 /**
786 * Display Admin Notice for Bulk Action
787 *
788 * @since 1.1.0
789 * @return void
790 */
791 public function bulk_action_admin_notice(): void {
792 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params, not form processing.
793 if (!isset($_GET['thinkrank_indexed_count']) || !isset($_GET['thinkrank_index_status'])) {
794 return;
795 }
796
797 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params.
798 $count = (int) $_GET['thinkrank_indexed_count'];
799 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params.
800 $status = sanitize_key(wp_unslash($_GET['thinkrank_index_status']));
801
802 if ($status === 'scheduled') {
803 $class = 'notice-success';
804 // translators: %s is the number of URLs queued for IndexNow submission.
805 $message = sprintf(_n('%s URL queued for submission to IndexNow.', '%s URLs queued for submission to IndexNow.', $count, 'thinkrank'), $count);
806 } else {
807 $class = ($status === 'success') ? 'notice-success' : 'notice-error';
808 $message = ($status === 'success')
809 // translators: %s is the number of URLs submitted to IndexNow.
810 ? sprintf(_n('%s URL submitted to IndexNow successfully.', '%s URLs submitted to IndexNow successfully.', $count, 'thinkrank'), $count)
811 : __('Failed to submit URLs to IndexNow.', 'thinkrank');
812 }
813
814 echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
815 }
816 }
817