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

831 lines 30.9 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 // Matches the scheme the submitted URLs go out with, so IndexNow is
155 // never told to verify ownership at an address on the other scheme.
156 return Url_Scheme::apply(home_url('/' . $api_key . '.txt'));
157 }
158
159 /**
160 * Actively verify that the advertised keyLocation is reachable and returns
161 * the key — the general safety net for #247.
162 *
163 * IndexNow only ever answers 403 when it cannot read a matching key at
164 * keyLocation, and that failure is otherwise silent until the first
165 * submission. On a read-only root the physical `<key>.txt` is never written,
166 * and the `parse_request` fallback only fires when the request actually
167 * reaches WordPress — which it does not on an Apache-style host running
168 * Plain permalinks. This does a one-shot loopback fetch of the exact URL we
169 * announce to IndexNow so any unreachable-key configuration (that one
170 * included) is caught on the settings screen instead of at first submit.
171 *
172 * @since 1.28.0
173 * @return array{reachable:bool,code:int,url:string,reason:string}
174 */
175 public function verify_key_reachable(): array {
176 $settings = get_option($this->option_name, []);
177 $api_key = (string) ($settings['api_key'] ?? '');
178 $url = $api_key !== '' ? self::key_location($api_key) : '';
179
180 $result = [
181 'reachable' => false,
182 'code' => 0,
183 'url' => $url,
184 'reason' => '',
185 ];
186
187 if ($api_key === '' || !preg_match('/^[a-f0-9]{8,64}$/', $api_key)) {
188 $result['reason'] = __('No valid IndexNow key is set yet.', 'thinkrank');
189 return $result;
190 }
191
192 // Loopback fetch of our own key URL. sslverify is off because this is a
193 // self-check against this very site (a self-signed/local cert must not
194 // read as "unreachable"), mirroring how WP Site Health runs its loopback
195 // probes.
196 $response = wp_remote_get(
197 $url,
198 [
199 'timeout' => 7,
200 'sslverify' => false,
201 // translators: this is a diagnostic self-request user agent.
202 'user-agent' => 'ThinkRank-IndexNow-KeyCheck/1.0',
203 ]
204 );
205
206 if (is_wp_error($response)) {
207 $result['reason'] = sprintf(
208 /* translators: %s: HTTP error message. */
209 __('Could not reach the key file from this server (%s). Search engines may still reach it; open it in a browser to confirm.', 'thinkrank'),
210 $response->get_error_message()
211 );
212 return $result;
213 }
214
215 $result['code'] = (int) wp_remote_retrieve_response_code($response);
216 $body = trim((string) wp_remote_retrieve_body($response));
217
218 if ($result['code'] === 200 && hash_equals($api_key, $body)) {
219 $result['reachable'] = true;
220 return $result;
221 }
222
223 $result['reason'] = $this->key_unreachable_reason($result['code']);
224 return $result;
225 }
226
227 /**
228 * Build an actionable explanation when the key file is not reachable, naming
229 * the specific #247 combination (read-only root + Plain permalinks) so the
230 * user gets a fix instead of a silent, permanent 403.
231 *
232 * @since 1.28.0
233 * @param int $code HTTP status observed for the key URL (0 when none).
234 * @return string
235 */
236 private function key_unreachable_reason(int $code): string {
237 $root_writable = wp_is_writable(ABSPATH);
238 $pretty = (bool) get_option('permalink_structure');
239
240 // The exact #247 trap: the file can't be written (read-only root) AND
241 // the server only routes unknown paths to WordPress under pretty
242 // permalinks, so the PHP fallback never runs either.
243 if (!$root_writable && !$pretty) {
244 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');
245 }
246
247 if ($code === 404) {
248 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');
249 }
250
251 return sprintf(
252 /* translators: %d: HTTP status code returned by the key URL. */
253 __('The key file could not be verified (HTTP %d). Open it in a browser — it should show the key and nothing else.', 'thinkrank'),
254 $code
255 );
256 }
257
258 /**
259 * Add Row Action Link
260 *
261 * @since 1.1.0
262 * @param array $actions Existing actions.
263 * @param \WP_Post $post Post object.
264 * @return array Modified actions.
265 */
266 public function add_row_action_link(array $actions, \WP_Post $post): array {
267 // Check if enabled and post type is supported
268 if (!$this->is_enabled() || !$this->is_post_type_supported($post->post_type)) {
269 return $actions;
270 }
271
272 // Check permissions
273 if (!current_user_can('edit_post', $post->ID)) {
274 return $actions;
275 }
276
277 $nonce = wp_create_nonce('thinkrank_instant_index_' . $post->ID);
278 $url = admin_url('admin.php?action=thinkrank_instant_index_single&post_id=' . $post->ID . '&nonce=' . $nonce);
279
280 $actions['thinkrank_instant_index'] = sprintf(
281 '<a href="%s">%s</a>',
282 esc_url($url),
283 esc_html__('ThinkRank: Instant Indexing Submit Page', 'thinkrank')
284 );
285
286 return $actions;
287 }
288
289 /**
290 * Handle Single Post Submission
291 *
292 * @since 1.1.0
293 * @return void
294 */
295 public function handle_single_action_submit(): void {
296 $post_id = isset($_GET['post_id']) ? (int) $_GET['post_id'] : 0;
297 $nonce = isset($_GET['nonce']) ? sanitize_text_field(wp_unslash($_GET['nonce'])) : '';
298
299 // Verify nonce
300 if (!wp_verify_nonce($nonce, 'thinkrank_instant_index_' . $post_id)) {
301 wp_die(esc_html__('Security check failed.', 'thinkrank'));
302 }
303
304 // Check permissions
305 if (!current_user_can('edit_post', $post_id)) {
306 wp_die(esc_html__('You do not have permission to edit this post.', 'thinkrank'));
307 }
308
309 $url = get_permalink($post_id);
310 $redirect_to = wp_get_referer() ?: admin_url('edit.php');
311
312 if ($url) {
313 $result = $this->submit_urls([$url]);
314
315 $redirect_to = add_query_arg([
316 'thinkrank_indexed_count' => 1,
317 'thinkrank_index_status' => $result['success'] ? 'success' : 'failed',
318 ], $redirect_to);
319 }
320
321 wp_safe_redirect($redirect_to);
322 exit;
323 }
324
325 /**
326 * Register Bulk Actions Hook
327 *
328 * @since 1.1.0
329 * @return void
330 */
331 public function register_bulk_actions_hook(): void {
332 add_filter('thinkrank_bulk_actions', [$this, 'add_bulk_action_item'], 10, 2);
333 }
334
335 /**
336 * Add Bulk Action Item to Dropdown
337 *
338 * @since 1.1.0
339 * @param array $actions Existing thinkrank actions.
340 * @param string $post_type Current post type.
341 * @return array Modified actions.
342 */
343 public function add_bulk_action_item(array $actions, string $post_type): array {
344 // Check if enabled and post type is supported
345 if (!$this->is_enabled() || !$this->is_post_type_supported($post_type)) {
346 return $actions;
347 }
348
349 $actions['thinkrank_instant_index'] = __('Instant Indexing: Submit Page', 'thinkrank');
350 return $actions;
351 }
352
353 /**
354 * Register handler hooks for bulk actions
355 *
356 * @since 1.1.0
357 * @return void
358 */
359 private function register_handler_hooks(): void {
360 $settings = get_option($this->option_name, []);
361 $supported_types = $settings['auto_submit_post_types'] ?? [];
362
363 foreach ($supported_types as $post_type) {
364 add_filter("handle_bulk_actions-edit-{$post_type}", [$this, 'handle_bulk_action_submit'], 10, 3);
365 }
366 }
367
368 /**
369 * Handle post status transitions (publish, update)
370 *
371 * @since 1.1.0
372 *
373 * @param string $new_status New post status.
374 * @param string $old_status Old post status.
375 * @param \WP_Post $post Post object.
376 * @return void
377 */
378 public function handle_post_transition(string $new_status, string $old_status, \WP_Post $post): void {
379
380 // Check if we should process this post
381 if (!$this->should_process_post($post)) {
382 return;
383 }
384
385 // We only care if the new status is publish (created or updated)
386 // OR if we are unpublishing (publish -> something else), we might want to update (though IndexNow is mostly for crawling new/updated content)
387 // For now, let's focus on published content.
388 if ($new_status === 'publish') {
389 $url = get_permalink($post->ID);
390
391 // Check for duplicate submission using short-lived cache (15 seconds)
392 if ($this->is_recently_submitted_cache($url)) {
393 return;
394 }
395
396 // Defer the outbound IndexNow call to WP-Cron so publishing doesn't
397 // block on a third-party HTTP request.
398 $this->schedule_url_submission([$url]);
399 }
400 }
401
402 /**
403 * Check if URL was recently submitted using transient cache
404 *
405 * @since 1.1.0
406 * @param string $url URL to check
407 * @return bool
408 */
409 private function is_recently_submitted_cache(string $url): bool {
410 $cache_key = 'thinkrank_indexing_' . md5($url);
411
412 if (get_transient($cache_key)) {
413 return true;
414 }
415
416 set_transient($cache_key, true, 15); // Cache for 15 seconds
417 return false;
418 }
419
420 /**
421 * Handle post deletion
422 *
423 * @since 1.1.0
424 *
425 * @param int $postid Post ID.
426 * @param \WP_Post $post Post object.
427 * @return void
428 */
429 public function handle_post_deletion(int $postid, \WP_Post $post): void {
430 // Check if we should process this post (even if it's being deleted, we might want to notify,
431 // though IndexNow 'submit' usually implies "please crawl this".
432 // IndexNow documentation says "notify... that a URL and its content has been added, updated, or deleted."
433 // So yes, we submit deleted URLs too if they were public.)
434
435 // For deletion, status might be 'trash' or 'delete', careful with checks.
436 // We only care if it was a supported post type.
437 if (!$this->is_post_type_supported($post->post_type)) {
438 return;
439 }
440
441 // If global setting disabled, abort
442 if (!$this->is_enabled()) {
443 return;
444 }
445
446 $url = get_permalink($postid);
447 if ($url) {
448 // Defer to WP-Cron so the delete request doesn't block on IndexNow.
449 $this->schedule_url_submission([$url]);
450 }
451 }
452
453 /**
454 * Check if a post should be processed
455 *
456 * @since 1.1.0
457 *
458 * @param \WP_Post $post Post object.
459 * @return bool True if should process, false otherwise.
460 */
461 private function should_process_post(\WP_Post $post): bool {
462 // 1. Check global enable switch
463 if (!$this->is_enabled()) {
464 return false;
465 }
466
467 // 2. Check if post type is supported
468 if (!$this->is_post_type_supported($post->post_type)) {
469 return false;
470 }
471
472 // 3. Check autosave/revision
473 if (wp_is_post_autosave($post) || wp_is_post_revision($post)) {
474 return false;
475 }
476
477 return true;
478 }
479
480 /**
481 * Check if feature is enabled globally
482 *
483 * @since 1.1.0
484 * @return bool
485 */
486 private function is_enabled(): bool {
487 $settings = get_option($this->option_name, []);
488 return isset($settings['enabled']) && $settings['enabled'];
489 }
490
491 /**
492 * Check if post type is in settings
493 *
494 * @since 1.1.0
495 * @param string $post_type The post type slug.
496 * @return bool
497 */
498 private function is_post_type_supported(string $post_type): bool {
499 $settings = get_option($this->option_name, []);
500 $supported_types = $settings['auto_submit_post_types'] ?? [];
501
502 return in_array($post_type, (array) $supported_types, true);
503 }
504
505 /**
506 * Submit URLs to IndexNow API
507 *
508 * @since 1.1.0
509 * @param array $urls List of URLs to submit.
510 * @return array Submission results.
511 */
512 public function submit_urls(array $urls): array {
513 if (empty($urls)) {
514 return ['success' => false, 'message' => 'No URLs provided', 'submitted_count' => 0];
515 }
516
517 $host = wp_parse_url(home_url(), PHP_URL_HOST);
518
519 // Only submit URLs on this site's host. IndexNow rejects a urlList whose
520 // entries don't match the declared host (HTTP 422), and the site's key
521 // must not be sent for foreign URLs — enforce it locally on every path.
522 $urls = array_values(array_filter($urls, static function ($u) use ($host) {
523 return strcasecmp((string) wp_parse_url((string) $u, PHP_URL_HOST), (string) $host) === 0;
524 }));
525 if (empty($urls)) {
526 return ['success' => false, 'message' => 'No URLs matched this site host', 'submitted_count' => 0];
527 }
528
529 // Every submission path lands here, so this is where the site's scheme
530 // preference is applied (#638). Submitting http URLs for a site served
531 // over https asks search engines to index an address that redirects,
532 // and it is the canonical mismatch all over again in the one place a
533 // site owner cannot see it happening.
534 $urls = array_values(array_unique(array_map(
535 static function ($u): string {
536 return Url_Scheme::apply((string) $u);
537 },
538 $urls
539 )));
540
541 // Enforce the shared per-submission cap so every path (manual, bulk, MCP)
542 // behaves consistently.
543 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
544 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
545 }
546
547 $settings = get_option($this->option_name, []);
548 $api_key = $settings['api_key'] ?? '';
549 if (empty($api_key)) {
550 return ['success' => false, 'message' => 'API Key missing', 'submitted_count' => 0];
551 }
552
553 $key_location = self::key_location($api_key);
554
555 $body = [
556 'host' => $host,
557 'key' => $api_key,
558 'keyLocation' => $key_location,
559 'urlList' => $urls
560 ];
561
562 $response = wp_remote_post($this->api_endpoint, [
563 'headers' => [
564 'Content-Type' => 'application/json; charset=utf-8'
565 ],
566 'body' => wp_json_encode($body),
567 'timeout' => 15,
568 'blocking' => true
569 ]);
570
571 // A network-layer failure (timeout, DNS, SSL) returns a WP_Error rather
572 // than an HTTP response — surface its message instead of logging an empty
573 // 0/'' row so the failure is diagnosable in the UI and history.
574 if (is_wp_error($response)) {
575 $status = 'failed';
576 $response_code = 0;
577 $response_message = $response->get_error_message();
578 } else {
579 $response_code = (int) wp_remote_retrieve_response_code($response);
580 $response_message = wp_remote_retrieve_response_message($response);
581 $status = ($response_code >= 200 && $response_code < 300) ? 'success' : 'failed';
582
583 // "403 Forbidden" is IndexNow's answer for exactly one problem —
584 // it could not read a matching key at keyLocation — but the raw
585 // status reads like a permissions error against the API and sent a
586 // customer hunting through the wrong settings for days. Say what it
587 // actually means, and name the URL to check.
588 if ($response_code === 403) {
589 $response_message = sprintf(
590 /* translators: %s: public URL of the IndexNow key file. */
591 __('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'),
592 $key_location
593 );
594 }
595 }
596
597 // Log each URL
598 foreach ($urls as $url) {
599 $this->log_submission($url, $status, $response_code, $response_message);
600 }
601
602 return [
603 'success' => $status === 'success',
604 'code' => $response_code,
605 'message' => $response_message,
606 // The count actually sent to IndexNow after same-host filtering and
607 // the per-submission cap, so callers report the real number instead
608 // of the raw input size.
609 'submitted_count' => count($urls),
610 'submitted_urls' => $urls,
611 ];
612 }
613
614 /**
615 * Queue a set of URLs for out-of-band submission to IndexNow.
616 *
617 * Used by the automatic (transition_post_status / delete_post) hooks so the
618 * blocking HTTP call runs on a WP-Cron request instead of the editor's save
619 * request. WP-Cron collapses identical (hook + args) events scheduled close
620 * together, which further de-dupes rapid repeat saves of the same URL.
621 *
622 * @since 1.16.0
623 * @param array $urls List of URLs to submit.
624 * @return void
625 */
626 private function schedule_url_submission(array $urls): void {
627 if (empty($urls)) {
628 return;
629 }
630
631 if (!wp_next_scheduled(self::CRON_SUBMIT_HOOK, [$urls])) {
632 wp_schedule_single_event(time(), self::CRON_SUBMIT_HOOK, [$urls]);
633 }
634 }
635
636 /**
637 * WP-Cron handler: perform the deferred IndexNow submission.
638 *
639 * @since 1.16.0
640 * @param array $urls List of URLs to submit.
641 * @return void
642 */
643 public function submit_urls_cron(array $urls): void {
644 // Re-check the feature is still enabled in case it was turned off between
645 // scheduling and execution.
646 if (!$this->is_enabled()) {
647 return;
648 }
649
650 $this->submit_urls($urls);
651 }
652
653 /**
654 * Log submission to database
655 *
656 * @param string $url URL submitted
657 * @param string $status success/failed
658 * @param int|string $code Response code
659 * @param string $message Response message
660 */
661 private function log_submission(string $url, string $status, $code, string $message): void {
662 global $wpdb;
663 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
664
665 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Logging requires direct insert.
666 $wpdb->insert(
667 $table_name,
668 [
669 'url' => $url,
670 'status' => $status,
671 'response_code' => $code,
672 'response_message' => $message,
673 'created_at' => current_time('mysql')
674 ],
675 ['%s', '%s', '%d', '%s', '%s']
676 );
677 }
678
679 /**
680 * Get submission history
681 *
682 * @param int $limit Number of records to retrieve
683 * @return array
684 */
685 public function get_history(int $limit = -1): array {
686 global $wpdb;
687 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
688
689 if ($limit === -1) {
690 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
691 return $wpdb->get_results(
692 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
693 "SELECT * FROM `{$table_name}` ORDER BY created_at DESC",
694 ARRAY_A
695 );
696 }
697
698 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
699 return $wpdb->get_results(
700 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
701 $wpdb->prepare("SELECT * FROM `{$table_name}` ORDER BY created_at DESC LIMIT %d", $limit),
702 ARRAY_A
703 );
704 }
705
706 /**
707 * Get a bounded page of submission history plus the total row count, so the
708 * History tab paginates server-side instead of fetching the whole table.
709 *
710 * @param int $page 1-based page number.
711 * @param int $per_page Rows per page (clamped 1..100).
712 * @return array{items: array, total: int, page: int, per_page: int}
713 */
714 public function get_history_page(int $page = 1, int $per_page = 10): array {
715 global $wpdb;
716 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
717
718 $per_page = max(1, min(100, $per_page));
719 $page = max(1, $page);
720 $offset = ($page - 1) * $per_page;
721
722 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
723 $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM `{$table_name}`");
724
725 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from controlled prefix
726 $items = $wpdb->get_results(
727 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $table_name is from $wpdb->prefix.
728 $wpdb->prepare("SELECT * FROM `{$table_name}` ORDER BY created_at DESC LIMIT %d OFFSET %d", $per_page, $offset),
729 ARRAY_A
730 );
731
732 return [
733 'items' => $items ?: [],
734 'total' => $total,
735 'page' => $page,
736 'per_page' => $per_page,
737 ];
738 }
739
740 /**
741 * Clear submission history
742 *
743 * @since 1.1.0
744 * @return bool
745 */
746 public function clear_history(): bool {
747 global $wpdb;
748 $table_name = $wpdb->prefix . 'thinkrank_instant_indexing_logs';
749
750 // 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
751 $result = $wpdb->query("TRUNCATE TABLE `{$table_name}`");
752
753 return $result !== false;
754 }
755
756 /**
757 * Handle Bulk Action Submission
758 *
759 * @since 1.1.0
760 * @param string $redirect_to Redirect URL.
761 * @param string $action Action name.
762 * @param array $post_ids Selected post IDs.
763 * @return string Modified redirect URL.
764 */
765 public function handle_bulk_action_submit(string $redirect_to, string $action, array $post_ids): string {
766 if ($action !== 'thinkrank_instant_index') {
767 return $redirect_to;
768 }
769
770 $urls = [];
771 foreach ($post_ids as $post_id) {
772 $url = get_permalink($post_id);
773 if ($url) {
774 $urls[] = $url;
775 }
776 }
777
778 if (empty($urls)) {
779 return $redirect_to;
780 }
781
782 // Cap the set and defer the outbound call to WP-Cron so a large bulk
783 // selection doesn't block the admin request (parity with the auto path).
784 if (count($urls) > self::MAX_URLS_PER_SUBMISSION) {
785 $urls = array_slice($urls, 0, self::MAX_URLS_PER_SUBMISSION);
786 }
787 $count = count($urls);
788 $this->schedule_url_submission($urls);
789
790 // Add query args for admin notice
791 $redirect_to = add_query_arg([
792 'thinkrank_indexed_count' => $count,
793 'thinkrank_index_status' => 'scheduled',
794 ], $redirect_to);
795
796 return $redirect_to;
797 }
798
799 /**
800 * Display Admin Notice for Bulk Action
801 *
802 * @since 1.1.0
803 * @return void
804 */
805 public function bulk_action_admin_notice(): void {
806 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params, not form processing.
807 if (!isset($_GET['thinkrank_indexed_count']) || !isset($_GET['thinkrank_index_status'])) {
808 return;
809 }
810
811 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params.
812 $count = (int) $_GET['thinkrank_indexed_count'];
813 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin notice display reads URL params.
814 $status = sanitize_key(wp_unslash($_GET['thinkrank_index_status']));
815
816 if ($status === 'scheduled') {
817 $class = 'notice-success';
818 // translators: %s is the number of URLs queued for IndexNow submission.
819 $message = sprintf(_n('%s URL queued for submission to IndexNow.', '%s URLs queued for submission to IndexNow.', $count, 'thinkrank'), $count);
820 } else {
821 $class = ($status === 'success') ? 'notice-success' : 'notice-error';
822 $message = ($status === 'success')
823 // translators: %s is the number of URLs submitted to IndexNow.
824 ? sprintf(_n('%s URL submitted to IndexNow successfully.', '%s URLs submitted to IndexNow successfully.', $count, 'thinkrank'), $count)
825 : __('Failed to submit URLs to IndexNow.', 'thinkrank');
826 }
827
828 echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
829 }
830 }
831