diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dd4b25c..47472c0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -39,7 +39,8 @@ pr-vision/ │ │ ├── class-prv-probe-runner.php # Orchestrates run: lock + peptide*intent*model loop │ │ ├── class-prv-collector-registry.php # Singleton registry for collectors + panels │ │ ├── class-prv-admin-page.php # Dashboard page: menu, Run now, rendering -│ │ ├── class-prv-settings-page.php # [v0.2] Settings page: POST handlers + AJAX dispatch +│ │ ├── class-prv-settings-page.php # [v0.2] Settings page: menu, Run-now, AJAX dispatch; delegates POST to PRV_Settings_Controller +│ │ ├── class-prv-settings-controller.php # [v0.2] POST handler impl: save config, model CRUD (split from settings-page) │ │ ├── class-prv-settings-renderer.php # [v0.2] Settings HTML renderer (dark Assay theme) │ │ ├── class-prv-model-manager-table.php # [v0.2] Model manager table + Add form renderer │ │ └── class-prv-model-test-ajax.php # [v0.2] Rate-limited AJAX handler for Test button diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec206a..2b6303a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,3 +102,4 @@ Admin/Settings interface + correctness fixes. All adversarial-QA must-fixes addr - **P1-C:** Added `phpcs:disable/enable WordPress.Security.NonceVerification.Missing` around `$_POST` blocks in all four `handle_*()` methods in `class-prv-settings-page.php`; nonce is verified via `require_admin_nonce()` private helper. - **P1-D/E:** Expanded inline associative arrays to multi-line in `class-prv-config.php`, `class-prv-probe-runner.php`, `class-prv-settings-renderer.php`, `class-prv-model-test-ajax.php`; fixed param-comment spacing in `class-prv-model-registry.php`; capitalised docblock long-description first word. - **PHPCS compliance:** Expanded remaining 5 inline associative arrays in `class-prv-settings-page.php` (`add_query_arg` calls + `PRV_Model_Registry::update` data array) to multi-line with trailing comma; corrected `@param` column alignment in `class-prv-probe-runner.php:update_api_key_status` and `class-prv-model-registry.php:update_health` to match longest-type rule. PHPCS: 0 errors / 0 warnings. +- **300-line split:** extracted `handle_save`, `handle_model_add`, `handle_model_update`, `handle_model_remove` from `class-prv-settings-page.php` (346 lines) into new `class-prv-settings-controller.php` (`PRV_Settings_Controller`); settings-page delegates via nonce callback. All `phpcs:disable/enable WordPress.Security.NonceVerification.Missing` wrappers carried with the moved handlers. Both files now ≤213 lines; no file exceeds 300 lines. diff --git a/includes/core/class-prv-settings-controller.php b/includes/core/class-prv-settings-controller.php new file mode 100644 index 0000000..8aac4e9 --- /dev/null +++ b/includes/core/class-prv-settings-controller.php @@ -0,0 +1,213 @@ +nonce_cb = $nonce_cb; + } + + /** + * Handle the settings save POST action. + * + * Validates, sanitizes, saves peptides/intents/cadence/budget. Bumps + * config-version if scoring-relevant fields changed. Reschedules cron + * if cadence changed. + * + * Side effects: Updates wp_options, may reschedule cron, redirects. + * + * @return void + */ + public function handle_save(): void { + ( $this->nonce_cb )( PRV_Settings_Page::NONCE_SAVE ); + $old_cadence = PRV_Config::get_cadence(); + + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). + $budget = isset( $_POST['prv_monthly_budget_usd'] ) + ? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) ) + : PRV_DEFAULT_MONTHLY_BUDGET_USD; + update_option( 'prv_monthly_budget_usd', $budget ); + + $cadence_raw = isset( $_POST['prv_cadence'] ) + ? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) ) + : 'weekly'; + $cadence = in_array( $cadence_raw, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence_raw : 'weekly'; + update_option( PRV_Config::CADENCE_KEY, $cadence ); + + if ( ! empty( $_POST['prv_peptides_json'] ) ) { + $raw_json = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_peptides_json'] ) ); + $decoded = json_decode( $raw_json, true ); + if ( is_array( $decoded ) ) { + $clean = array(); + foreach ( $decoded as $p ) { + if ( isset( $p['slug'], $p['label'] ) ) { + $clean[] = array( + 'slug' => sanitize_text_field( (string) $p['slug'] ), + 'label' => sanitize_text_field( (string) $p['label'] ), + ); + } + } + update_option( 'prv_peptides', $clean ); + } + } + + if ( isset( $_POST['prv_prompt_intents_text'] ) ) { + $raw_intents = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_prompt_intents_text'] ) ); + $intent_lines = array_filter( array_map( 'trim', explode( "\n", $raw_intents ) ) ); + update_option( 'prv_prompt_intents', array_values( $intent_lines ) ); + } + // phpcs:enable WordPress.Security.NonceVerification.Missing + + PRV_Config_Version::bump_version_if_changed(); + + if ( $cadence !== $old_cadence ) { + PRV_Cron::reschedule( $cadence ); + } + + wp_safe_redirect( + add_query_arg( + array( + 'page' => self::MENU_SLUG, + 'prv_saved' => 1, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * Handle Add model POST. + * + * Side effects: Adds to prv_models; may bump config-version; redirects. + * + * @return void + */ + public function handle_model_add(): void { + ( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL ); + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). + $slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) ); + $provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) ); + $note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) ); + $enabled = ! empty( $_POST['prv_model_enabled'] ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + if ( '' !== $slug ) { + PRV_Model_Registry::add( $slug, $provider, $enabled, $note ); + PRV_Config_Version::bump_version_if_changed(); + } + wp_safe_redirect( + add_query_arg( + array( + 'page' => self::MENU_SLUG, + 'prv_model_added' => 1, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * Handle Update model POST. + * + * Side effects: Updates prv_models; may bump config-version; redirects. + * + * @return void + */ + public function handle_model_update(): void { + ( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL ); + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). + $id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) ); + $slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) ); + $enabled = ! empty( $_POST['prv_model_enabled'] ); + $note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + if ( '' !== $id && '' !== $slug ) { + PRV_Model_Registry::update( + $id, + array( + 'slug' => $slug, + 'enabled' => $enabled, + 'note' => $note, + ) + ); + PRV_Config_Version::bump_version_if_changed(); + } + wp_safe_redirect( + add_query_arg( + array( + 'page' => self::MENU_SLUG, + 'prv_model_updated' => 1, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } + + /** + * Handle Remove model POST. + * + * Side effects: Removes from prv_models; may bump config-version; redirects. + * + * @return void + */ + public function handle_model_remove(): void { + ( $this->nonce_cb )( PRV_Settings_Page::NONCE_MODEL ); + // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). + $id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) ); + // phpcs:enable WordPress.Security.NonceVerification.Missing + if ( '' !== $id ) { + PRV_Model_Registry::remove( $id ); + PRV_Config_Version::bump_version_if_changed(); + } + wp_safe_redirect( + add_query_arg( + array( + 'page' => self::MENU_SLUG, + 'prv_model_removed' => 1, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } +} diff --git a/includes/core/class-prv-settings-page.php b/includes/core/class-prv-settings-page.php index 93dd869..a7188b4 100644 --- a/includes/core/class-prv-settings-page.php +++ b/includes/core/class-prv-settings-page.php @@ -10,21 +10,22 @@ declare(strict_types=1); /** * PR Vision Settings admin page. * - * Registers "PR Vision > Settings" sub-menu, handles POST actions (save config, - * model CRUD, Run now) and the test-model AJAX hook. Rendering is delegated - * to PRV_Settings_Renderer; test AJAX to PRV_Model_Test_Ajax. + * Registers "PR Vision > Settings" sub-menu, dispatches POST actions to + * PRV_Settings_Controller, handles Run-now and AJAX test-model, and + * delegates all HTML rendering to PRV_Settings_Renderer. * * All actions require: current_user_can('manage_options') + valid nonce. * * Who triggers: PRV_Plugin::init() -- is_admin() guard. - * Dependencies: PRV_Model_Registry, PRV_Config, PRV_Cron, PRV_Run_Lock, - * PRV_Config_Version, PRV_Settings_Renderer, PRV_Model_Test_Ajax. + * Dependencies: PRV_Settings_Controller, PRV_Settings_Renderer, + * PRV_Model_Test_Ajax, PRV_Probe_Runner, PRV_Run_Lock. * - * @see class-prv-settings-renderer.php -- Renders the HTML. - * @see class-prv-model-test-ajax.php -- AJAX test handler. - * @see class-prv-model-registry.php -- CRUD for prv_models. - * @see class-prv-config-version.php -- Config-change versioning. - * @see class-prv-cron.php -- Reschedule on cadence change. + * @see class-prv-settings-controller.php -- POST handler implementations. + * @see class-prv-settings-renderer.php -- Renders the HTML. + * @see class-prv-model-test-ajax.php -- AJAX test handler. + * @see class-prv-model-registry.php -- CRUD for prv_models. + * @see class-prv-config-version.php -- Config-change versioning. + * @see class-prv-cron.php -- Reschedule on cadence change. * @package PrVision */ class PRV_Settings_Page { @@ -47,16 +48,25 @@ class PRV_Settings_Page { /** * Register WordPress admin hooks. * + * POST handlers for save/model-CRUD are delegated to PRV_Settings_Controller, + * which receives a nonce-verification callback bound to this page. + * * Side effects: Adds admin_menu, admin_post, and wp_ajax actions. * * @return void */ public function register_hooks(): void { + $ctrl = new PRV_Settings_Controller( + function ( string $action ): void { + $this->require_admin_nonce( $action ); + } + ); + add_action( 'admin_menu', array( $this, 'add_settings_menu' ) ); - add_action( 'admin_post_prv_settings_save', array( $this, 'handle_save' ) ); - add_action( 'admin_post_prv_model_add', array( $this, 'handle_model_add' ) ); - add_action( 'admin_post_prv_model_update', array( $this, 'handle_model_update' ) ); - add_action( 'admin_post_prv_model_remove', array( $this, 'handle_model_remove' ) ); + add_action( 'admin_post_prv_settings_save', array( $ctrl, 'handle_save' ) ); + add_action( 'admin_post_prv_model_add', array( $ctrl, 'handle_model_add' ) ); + add_action( 'admin_post_prv_model_update', array( $ctrl, 'handle_model_update' ) ); + add_action( 'admin_post_prv_model_remove', array( $ctrl, 'handle_model_remove' ) ); add_action( 'admin_post_prv_settings_run_now', array( $this, 'handle_run_now' ) ); add_action( 'wp_ajax_prv_test_model', array( $this, 'handle_test_model' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); @@ -100,172 +110,6 @@ class PRV_Settings_Page { ); } - /** - * Handle the settings save POST action. - * - * Validates, sanitizes, saves peptides/intents/cadence/budget. Bumps - * config-version if scoring-relevant fields changed. Reschedules cron - * if cadence changed. - * - * Side effects: Updates wp_options, may reschedule cron, redirects. - * - * @return void - */ - public function handle_save(): void { - $this->require_admin_nonce( self::NONCE_SAVE ); - $old_cadence = PRV_Config::get_cadence(); - - // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). - $budget = isset( $_POST['prv_monthly_budget_usd'] ) - ? max( 0.01, (float) sanitize_text_field( wp_unslash( (string) $_POST['prv_monthly_budget_usd'] ) ) ) - : PRV_DEFAULT_MONTHLY_BUDGET_USD; - update_option( 'prv_monthly_budget_usd', $budget ); - - $cadence_raw = isset( $_POST['prv_cadence'] ) - ? sanitize_text_field( wp_unslash( (string) $_POST['prv_cadence'] ) ) - : 'weekly'; - $cadence = in_array( $cadence_raw, array( 'weekly', 'daily', 'twicedaily' ), true ) ? $cadence_raw : 'weekly'; - update_option( PRV_Config::CADENCE_KEY, $cadence ); - - if ( ! empty( $_POST['prv_peptides_json'] ) ) { - $raw_json = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_peptides_json'] ) ); - $decoded = json_decode( $raw_json, true ); - if ( is_array( $decoded ) ) { - $clean = array(); - foreach ( $decoded as $p ) { - if ( isset( $p['slug'], $p['label'] ) ) { - $clean[] = array( - 'slug' => sanitize_text_field( (string) $p['slug'] ), - 'label' => sanitize_text_field( (string) $p['label'] ), - ); - } - } - update_option( 'prv_peptides', $clean ); - } - } - - if ( isset( $_POST['prv_prompt_intents_text'] ) ) { - $raw_intents = sanitize_textarea_field( wp_unslash( (string) $_POST['prv_prompt_intents_text'] ) ); - $intent_lines = array_filter( array_map( 'trim', explode( "\n", $raw_intents ) ) ); - update_option( 'prv_prompt_intents', array_values( $intent_lines ) ); - } - // phpcs:enable WordPress.Security.NonceVerification.Missing - - PRV_Config_Version::bump_version_if_changed(); - - if ( $cadence !== $old_cadence ) { - PRV_Cron::reschedule( $cadence ); - } - - wp_safe_redirect( - add_query_arg( - array( - 'page' => self::MENU_SLUG, - 'prv_saved' => 1, - ), - admin_url( 'admin.php' ) - ) - ); - exit; - } - - /** - * Handle Add model POST. - * - * Side effects: Adds to prv_models; may bump config-version; redirects. - * - * @return void - */ - public function handle_model_add(): void { - $this->require_admin_nonce( self::NONCE_MODEL ); - // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). - $slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) ); - $provider = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_provider'] ?? 'openrouter' ) ) ); - $note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) ); - $enabled = ! empty( $_POST['prv_model_enabled'] ); - // phpcs:enable WordPress.Security.NonceVerification.Missing - if ( '' !== $slug ) { - PRV_Model_Registry::add( $slug, $provider, $enabled, $note ); - PRV_Config_Version::bump_version_if_changed(); - } - wp_safe_redirect( - add_query_arg( - array( - 'page' => self::MENU_SLUG, - 'prv_model_added' => 1, - ), - admin_url( 'admin.php' ) - ) - ); - exit; - } - - /** - * Handle Update model POST. - * - * Side effects: Updates prv_models; may bump config-version; redirects. - * - * @return void - */ - public function handle_model_update(): void { - $this->require_admin_nonce( self::NONCE_MODEL ); - // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). - $id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) ); - $slug = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_slug'] ?? '' ) ) ); - $enabled = ! empty( $_POST['prv_model_enabled'] ); - $note = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_note'] ?? '' ) ) ); - // phpcs:enable WordPress.Security.NonceVerification.Missing - if ( '' !== $id && '' !== $slug ) { - PRV_Model_Registry::update( - $id, - array( - 'slug' => $slug, - 'enabled' => $enabled, - 'note' => $note, - ) - ); - PRV_Config_Version::bump_version_if_changed(); - } - wp_safe_redirect( - add_query_arg( - array( - 'page' => self::MENU_SLUG, - 'prv_model_updated' => 1, - ), - admin_url( 'admin.php' ) - ) - ); - exit; - } - - /** - * Handle Remove model POST. - * - * Side effects: Removes from prv_models; may bump config-version; redirects. - * - * @return void - */ - public function handle_model_remove(): void { - $this->require_admin_nonce( self::NONCE_MODEL ); - // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce verified above via require_admin_nonce(). - $id = sanitize_text_field( wp_unslash( (string) ( $_POST['prv_model_id'] ?? '' ) ) ); - // phpcs:enable WordPress.Security.NonceVerification.Missing - if ( '' !== $id ) { - PRV_Model_Registry::remove( $id ); - PRV_Config_Version::bump_version_if_changed(); - } - wp_safe_redirect( - add_query_arg( - array( - 'page' => self::MENU_SLUG, - 'prv_model_removed' => 1, - ), - admin_url( 'admin.php' ) - ) - ); - exit; - } - /** * Handle Run now POST from the settings page. * @@ -330,11 +174,14 @@ class PRV_Settings_Page { /** * Verify admin capability and nonce; wp_die on failure. * + * Called directly for Run-now, and passed as a callback to + * PRV_Settings_Controller for the four POST handlers. + * * @param string $action Nonce action. * * @return void */ - private function require_admin_nonce( string $action ): void { + public function require_admin_nonce( string $action ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'pr-vision' ), 403 ); }