| @@ -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 []; |