PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.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 All 50 releases
← All changes | includes/api/class-llms-txt-endpoint.php +97 -52 1.0.0 → 2.9.0 View file →
@@ -18,15 +18,22 @@
18 18
19 19 use ThinkRank\SEO\LLMs_Txt_Manager;
20 20 use ThinkRank\AI\Manager as AI_Manager;
21 21 use ThinkRank\API\Traits\CSRF_Protection;
22 +use ThinkRank\API\Traits\Context_Authorization;
22 23 use WP_REST_Controller;
23 24 use WP_REST_Request;
24 25 use WP_REST_Response;
25 26 use WP_Error;
26 27
28 +// Prevent direct access
29 +if (!defined('ABSPATH')) {
30 + exit;
31 +}
32 +
27 33 // Load CSRF Protection trait
28 34 require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-csrf-protection.php';
35 +require_once THINKRANK_PLUGIN_DIR . 'includes/api/traits/trait-context-authorization.php';
29 36
30 37 /**
31 38 * LLMs.txt API Endpoints Class
32 39 *
@@ -37,8 +44,9 @@
37 44 * @since 1.0.0
38 45 */
39 46 class LLMs_Txt_Endpoint extends WP_REST_Controller {
40 47 use CSRF_Protection;
48 + use Context_Authorization;
41 49
42 50 /**
43 51 * LLMs.txt Manager instance
44 52 *
@@ -119,14 +127,15 @@
119 127 [
120 128 [
121 129 'methods' => 'GET',
122 130 'callback' => [$this, 'get_settings'],
123 - 'permission_callback' => [$this, 'check_read_permissions']
131 + 'permission_callback' => [$this, 'check_permissions'],
132 + 'args' => $this->get_context_route_args()
124 133 ],
125 134 [
126 135 'methods' => 'POST',
127 136 'callback' => [$this, 'update_settings'],
128 - 'permission_callback' => [$this, 'check_csrf_permissions'],
137 + 'permission_callback' => [$this, 'check_permissions'],
129 138 'args' => $this->get_settings_args()
130 139 ]
131 140 ]
132 141 );
@@ -138,9 +147,9 @@
138 147 [
139 148 [
140 149 'methods' => 'POST',
141 150 'callback' => [$this, 'generate_llms_txt'],
142 - 'permission_callback' => [$this, 'check_csrf_permissions'],
151 + 'permission_callback' => [$this, 'check_permissions'],
143 152 'args' => $this->get_generation_args()
144 153 ]
145 154 ]
146 155 );
@@ -152,9 +161,9 @@
152 161 [
153 162 [
154 163 'methods' => 'POST',
155 164 'callback' => [$this, 'ai_optimize_llms_txt'],
156 - 'permission_callback' => [$this, 'check_csrf_permissions'],
165 + 'permission_callback' => [$this, 'check_permissions'],
157 166 'args' => $this->get_ai_optimization_args()
158 167 ]
159 168 ]
160 169 );
@@ -166,9 +175,9 @@
166 175 [
167 176 [
168 177 'methods' => 'GET',
169 178 'callback' => [$this, 'get_llms_txt_status'],
170 - 'permission_callback' => [$this, 'check_read_permissions']
179 + 'permission_callback' => [$this, 'check_permissions']
171 180 ]
172 181 ]
173 182 );
174 183
@@ -179,9 +188,11 @@
179 188 [
180 189 [
181 190 'methods' => 'POST',
182 191 'callback' => [$this, 'validate_llms_txt_settings'],
183 - 'permission_callback' => [$this, 'check_read_permissions'],
192 + // Require thinkrank_crawling like every other llms-txt route
193 + // (was 'read', which let any subscriber hit this admin tool).
194 + 'permission_callback' => [$this, 'check_permissions'],
184 195 'args' => $this->get_validation_args()
185 196 ]
186 197 ]
187 198 );
@@ -193,9 +204,21 @@
193 204 [
194 205 [
195 206 'methods' => 'GET',
196 207 'callback' => [$this, 'get_optimization_results'],
197 - 'permission_callback' => [$this, 'check_read_permissions']
208 + 'permission_callback' => [$this, 'check_permissions'],
209 + // The handler reads `limit` and forwards it to a prepared
210 + // LIMIT %d. Not injectable, but unbounded — and unregistered
211 + // means no coercion either (#394).
212 + 'args' => [
213 + 'limit' => [
214 + 'required' => false,
215 + 'type' => 'integer',
216 + 'default' => 20,
217 + 'minimum' => 1,
218 + 'maximum' => 100,
219 + ],
220 + ]
198 221 ]
199 222 ]
200 223 );
201 224
@@ -206,9 +229,9 @@
206 229 [
207 230 [
208 231 'methods' => 'GET',
209 232 'callback' => [$this, 'get_overview_data'],
210 - 'permission_callback' => [$this, 'check_read_permissions']
233 + 'permission_callback' => [$this, 'check_permissions']
211 234 ]
212 235 ]
213 236 );
214 237 }
@@ -218,14 +241,19 @@
218 241 *
219 242 * @since 1.0.0
220 243 *
221 244 * @param WP_REST_Request $request Request object
222 - * @return WP_REST_Response Response object
245 + * @return WP_REST_Response|WP_Error Response object, or the context error
223 246 */
224 - public function get_settings(WP_REST_Request $request): WP_REST_Response {
247 + public function get_settings(WP_REST_Request $request) {
225 248 try {
226 - $context_type = $request->get_param('context_type') ?? 'site';
227 - $context_id = $request->get_param('context_id');
249 + // SECURITY: the settings are stored per context, so the object has
250 + // to be authorised before it is read (#385).
251 + $context = $this->resolve_request_context($request);
252 + if (is_wp_error($context)) {
253 + return $context;
254 + }
255 + [$context_type, $context_id] = $context;
228 256
229 257 // Get settings from LLMs.txt Manager
230 258 $settings = $this->llms_txt_manager->get_settings($context_type, $context_id);
231 259
@@ -261,11 +289,17 @@
261 289 */
262 290 public function update_settings(WP_REST_Request $request) {
263 291 try {
264 292 $settings = $request->get_param('settings');
265 - $context_type = $request->get_param('context_type') ?? 'site';
266 - $context_id = $request->get_param('context_id');
267 293
294 + // SECURITY: this write is keyed by the context, so the object has to
295 + // be authorised before anything is persisted (#385).
296 + $context = $this->resolve_request_context($request);
297 + if (is_wp_error($context)) {
298 + return $context;
299 + }
300 + [$context_type, $context_id] = $context;
301 +
268 302 // Validate settings
269 303 if (empty($settings) || !is_array($settings)) {
270 304 return new WP_Error(
271 305 'invalid_settings',
@@ -299,8 +333,44 @@
299 333 ['status' => 500]
300 334 );
301 335 }
302 336
337 + // The settings persisted, but a disable-save may have failed to remove
338 + // the published llms.txt file. Report that explicitly so the caller
339 + // knows /llms.txt might still be served rather than assuming success.
340 + if ($this->llms_txt_manager->unpublish_failed()) {
341 + return new WP_REST_Response([
342 + 'success' => true,
343 + 'file_unpublished' => false,
344 + 'data' => [
345 + 'settings' => $settings,
346 + 'validation' => $validation
347 + ],
348 + 'message' => 'Settings were saved, but the published llms.txt file could not be removed and may still be served. Please remove it manually.'
349 + ], 200);
350 + }
351 +
352 + // A delivery-mode switch that could not move the already-published
353 + // document leaves /llms.txt on the old path; say so instead of
354 + // reporting a clean save. A switch that worked but landed on a
355 + // server that answers the file without a charset warns under its own
356 + // key — reporting that one as a failed switch misdescribes it.
357 + $delivery_warning = $this->llms_txt_manager->delivery_switch_warning();
358 +
359 + if ('' !== $delivery_warning) {
360 + $switch_failed = $this->llms_txt_manager->delivery_switch_failed();
361 +
362 + return new WP_REST_Response([
363 + 'success' => true,
364 + ($switch_failed ? 'delivery_switch_failed' : 'delivery_warning') => true,
365 + 'data' => [
366 + 'settings' => $settings,
367 + 'validation' => $validation
368 + ],
369 + 'message' => $delivery_warning
370 + ], 200);
371 + }
372 +
303 373 return new WP_REST_Response([
304 374 'success' => true,
305 375 'data' => [
306 376 'settings' => $settings,
@@ -527,9 +597,9 @@
527 597 }
528 598
529 599 $analysis_type = 'llms_txt_ai_optimization';
530 600
531 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO analysis storage requires direct database access
601 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO analysis storage requires direct database access
532 602 $wpdb->insert(
533 603 $table_name,
534 604 [
535 605 'context_type' => 'site',
@@ -560,9 +630,9 @@
560 630 $table_name = $wpdb->prefix . 'thinkrank_seo_analysis';
561 631 $limit = $request->get_param('limit') ?? 5; // Default to 5 recent results
562 632
563 633 // Get recent LLMs.txt optimization results
564 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- SEO analysis retrieval requires direct database access
634 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- SEO analysis retrieval requires direct database access
565 635 $sql = "SELECT analysis_data, recommendations, score, created_at
566 636 FROM {$table_name}
567 637 WHERE analysis_type = %s AND context_type = %s
568 638 ORDER BY created_at DESC
@@ -567,11 +637,11 @@
567 637 WHERE analysis_type = %s AND context_type = %s
568 638 ORDER BY created_at DESC
569 639 LIMIT %d";
570 640
571 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Analysis data retrieval requires direct database access
641 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Analysis data retrieval requires direct database access
572 642 $results = $wpdb->get_results(
573 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name is validated with WordPress prefix, SQL is properly prepared
643 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix, SQL is properly prepared
574 644 $wpdb->prepare($sql, 'llms_txt_ai_optimization', 'site', $limit)
575 645 );
576 646
577 647 if (!$results) {
@@ -695,40 +765,10 @@
695 765 *
696 766 * @return bool Permission status
697 767 */
698 768 public function check_permissions(): bool {
699 - return current_user_can('manage_options');
769 + return \ThinkRank\Core\Capability_Manager::current_user_can('thinkrank_crawling');
700 770 }
701 -
702 - /**
703 - * Helper methods
704 - */
705 -
706 - /**
707 - * Validate context type and ID
708 - *
709 - * @since 1.0.0
710 - *
711 - * @param string $context_type Context type
712 - * @param int|null $context_id Context ID
713 - * @return bool Validation status
714 - */
715 - private function validate_context(string $context_type): bool {
716 - // Only support 'site' context in development stage
717 - return $context_type === 'site';
718 - }
719 -
720 - /**
721 - * Argument validation methods
722 - */
723 -
724 - /**
725 - * Get arguments for settings endpoints
726 - *
727 - * @since 1.0.0
728 - *
729 - * @return array Arguments array
730 - */
731 771 private function get_settings_args(): array {
732 772 return [
733 773 'settings' => [
734 774 'required' => true,
@@ -902,9 +942,14 @@
902 942 'optimization_history' => $optimization_history
903 943 ]
904 944 ], 200);
905 945
906 - } catch (Exception $e) {
946 + } catch (\Exception $e) {
947 + // Was `catch (Exception $e)` inside `namespace ThinkRank\API;` with
948 + // no `use Exception;`, so it resolved to ThinkRank\API\Exception —
949 + // a class that does not exist. The catch never matched and every
950 + // exception escaped as a fatal. Every other catch in this file
951 + // already uses the leading backslash (#394).
907 952 return new WP_REST_Response([
908 953 'success' => false,
909 954 'error' => 'Failed to load overview data: ' . $e->getMessage()
910 955 ], 500);
@@ -934,11 +979,11 @@
934 979 WHERE action = 'llms_txt_optimization'
935 980 ORDER BY created_at DESC
936 981 LIMIT %d";
937 982
938 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Usage analytics retrieval requires direct database access
983 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Usage analytics retrieval requires direct database access
939 984 $results = $wpdb->get_results(
940 - // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- Table name is validated with WordPress prefix, SQL is properly prepared
985 + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is validated with WordPress prefix, SQL is properly prepared
941 986 $wpdb->prepare($sql, $limit), ARRAY_A);
942 987
943 988 if (empty($results)) {
944 989 return [];