-options", and * preserve_active_tab_on_save() re-verifies that same nonce. * * @since 1.6.0 * @var string */ const SETTINGS_OPTION_GROUP = 'gatewell_settings_group'; /** * Constructor. * * @since 1.0.0 */ public function __construct() { // Grant the plugin capability. Runs early so admin_menu sees it. add_filter( 'user_has_cap', array( $this, 'grant_plugin_capability' ), 10, 4 ); // Register admin menu. add_action( 'admin_menu', array( $this, 'register_admin_menu' ) ); // Register settings. add_action( 'admin_init', array( $this, 'register_settings' ) ); // Preserve active tab after settings save. add_filter( 'wp_redirect', array( $this, 'preserve_active_tab_on_save' ) ); // Enqueue admin scripts and styles. add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) ); // Initialize metadata scanner if ( is_admin() ) { new \Gatewell\Metadata_Scanner(); } } /** * Parent menu slug for every Gatewell screen. * * 'gatewell-dashboard' is Gatewell's own top-level menu; 'tools.php' places * the screens under Tools > ... instead, per the "Menu location" setting. * Screens stay reachable at admin.php?page= in both modes. * * @since 1.7.0 * @return string */ public static function menu_parent() { $ui_options = get_option( 'gatewell_ui_options', array() ); $tools = is_array( $ui_options ) && isset( $ui_options['menu_location'] ) && 'tools' === $ui_options['menu_location']; return $tools ? 'tools.php' : 'gatewell-dashboard'; } /** * Submenu label. Under Tools there is no "Gatewell" heading above the * items, so each label is prefixed with the brand name. * * @since 1.7.0 * @param string $label Plain label, e.g. "Settings". * @return string */ public static function submenu_label( $label ) { if ( 'tools.php' !== self::menu_parent() ) { return $label; } return self::get_brand_name() . ' ' . $label; } /** * Register admin menu and submenus. * * @since 1.0.0 */ public function register_admin_menu() { // Check if current user has access to the plugin if ( ! $this->user_has_plugin_access() ) { return; // Don't show menu to users without access } // Add main menu page (lands on Dashboard). $icon_svg = ''; $parent = self::menu_parent(); if ( 'gatewell-dashboard' === $parent ) { add_menu_page( self::get_brand_name(), // Page title self::get_brand_name(), // Menu title self::REQUIRED_CAPABILITY, // Capability 'gatewell-dashboard', // Menu slug array( $this, 'render_dashboard_page' ), // Callback 'data:image/svg+xml;base64,' . base64_encode( $icon_svg ), // Icon 30 // Position ); } // Dashboard submenu (same slug as parent = first item, controls parent link destination). add_submenu_page( $parent, ( 'tools.php' === $parent ? self::get_brand_name() : __( 'Dashboard', 'gatewell' ) ), ( 'tools.php' === $parent ? self::get_brand_name() : __( 'Dashboard', 'gatewell' ) ), self::REQUIRED_CAPABILITY, 'gatewell-dashboard', array( $this, 'render_dashboard_page' ) ); // Settings submenu. add_submenu_page( $parent, self::submenu_label( __( 'Settings', 'gatewell' ) ), self::submenu_label( __( 'Settings', 'gatewell' ) ), self::REQUIRED_CAPABILITY, 'gatewell-settings', array( $this, 'render_settings_page' ) ); // Metadata Scanner submenu. add_submenu_page( $parent, self::submenu_label( __( 'Metadata Scanner', 'gatewell' ) ), self::submenu_label( __( 'Metadata Scanner', 'gatewell' ) ), self::REQUIRED_CAPABILITY, 'gatewell-metadata-scanner', array( $this, 'render_metadata_scanner_page' ) ); // System Info submenu. This screen exposes server paths and PHP // configuration, so it is gated on manage_options directly rather than on // the (delegable) plugin capability. add_submenu_page( $parent, self::submenu_label( __( 'System Info', 'gatewell' ) ), self::submenu_label( __( 'System Info', 'gatewell' ) ), 'manage_options', 'gatewell-system-info', array( $this, 'render_system_info_page' ) ); } /** * Append the active tab hash to the redirect URL after settings save. * * Hooked on `wp_redirect`, which fires for every redirect WordPress issues, * so this runs far outside our own screens. It therefore re-establishes the * full trust chain before it reads anything from the request: * * 1. the redirect must be heading back to our own settings screen; * 2. the current user must be able to manage options; * 3. the request must carry the valid Settings API nonce that options.php * itself checks (`-options`, registered by * settings_fields( 'gatewell_settings_group' ) in the settings view). * * Any of those failing returns $location untouched. * * @since 1.2.0 * @since 1.6.0 Added capability and nonce verification before reading $_POST. * * @param string $location Redirect URL. * @return string Modified redirect URL with tab hash. */ public function preserve_active_tab_on_save( $location ) { // Only act on redirects from options.php back to our settings page. if ( ! is_string( $location ) || false === strpos( $location, 'gatewell-settings' ) ) { return $location; } // Only a user who is allowed to save these settings may steer the hash. if ( ! current_user_can( 'manage_options' ) ) { return $location; } // Verify the Settings API nonce for our own option group before touching // any POST data. options.php signs the save with this action. if ( ! isset( $_POST['_wpnonce'] ) ) { return $location; } $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ); if ( ! wp_verify_nonce( $nonce, self::SETTINGS_OPTION_GROUP . '-options' ) ) { return $location; } if ( ! isset( $_POST['gatewell_active_tab'] ) ) { return $location; } $tab = sanitize_key( wp_unslash( $_POST['gatewell_active_tab'] ) ); if ( '' === $tab ) { return $location; } // Remove any existing fragment and append the active tab. $location = preg_replace( '/#.*$/', '', $location ); $location .= '#' . $tab; return $location; } /** * Register plugin settings. * * @since 1.0.0 */ public function register_settings() { /* * Every option registered to this group MUST have a form field rendered in * includes/admin/views/. WordPress core (wp-admin/options.php) passes null to * the sanitize callback of any registered option that is absent from $_POST and * then saves that null, so a registered option with no field is silently wiped * on every save. build-free.sh enforces the pairing. */ register_setting( 'gatewell_settings_group', 'gatewell_form_fields', array( $this, 'validate_form_fields' ) ); register_setting( 'gatewell_settings_group', 'gatewell_field_minimums', array( $this, 'validate_field_minimums' ) ); register_setting( 'gatewell_settings_group', 'gatewell_size_limits', array( $this, 'validate_size_limits' ) ); register_setting( 'gatewell_settings_group', 'gatewell_compression_quality', array( $this, 'validate_compression' ) ); register_setting( 'gatewell_settings_group', 'gatewell_format_conversion', array( $this, 'validate_format_conversion' ) ); register_setting( 'gatewell_settings_group', 'gatewell_original_storage', array( $this, 'validate_storage' ) ); register_setting( 'gatewell_settings_group', 'gatewell_allowed_formats', array( $this, 'validate_formats' ) ); register_setting( 'gatewell_settings_group', 'gatewell_ui_options', array( $this, 'validate_ui_options' ) ); register_setting( 'gatewell_settings_group', 'gatewell_require_unique_fields', array( $this, 'validate_checkbox' ) ); register_setting( 'gatewell_settings_group', 'gatewell_uniqueness_threshold', array( $this, 'validate_uniqueness_threshold' ) ); // Register File Type Controls settings (Phase 3 & 4) register_setting( 'gatewell_settings_group', 'gatewell_mime_mode', array( $this, 'validate_mime_mode' ) ); register_setting( 'gatewell_settings_group', 'gatewell_mime_whitelist', array( $this, 'validate_mime_whitelist' ) ); register_setting( 'gatewell_settings_group', 'gatewell_mime_blacklist', array( $this, 'validate_mime_blacklist' ) ); register_setting( 'gatewell_settings_group', 'gatewell_naming_conventions', array( $this, 'validate_naming_conventions' ) ); /* * gatewell_plugin_cooperation is deliberately NOT registered here. It has no * form field on any tab, and a registered option with no field is passed null * by wp-admin/options.php on every save — which wiped the stored mode and made * validate_plugin_cooperation() raise "Invalid cooperation mode selected." on * every settings save. Plugin_Compatibility::get_cooperation_mode() reads it * with get_option() and an 'auto' default instead. */ // Register feature flag settings (Phase 3). \GATEWELL_Feature_Flags::register_settings(); // Add settings sections (we'll populate these in Phase 2A). add_settings_section( 'gatewell_form_fields_section', __( 'Upload Form Fields', 'gatewell' ), array( $this, 'form_fields_section_callback' ), 'gatewell-settings' ); add_settings_section( 'gatewell_size_limits_section', __( 'Image Size Limits', 'gatewell' ), array( $this, 'size_limits_section_callback' ), 'gatewell-settings' ); } /** * Render the main settings page. * * @since 1.0.0 */ public function render_settings_page() { // Check if user has plugin access (role-based). if ( ! $this->user_has_plugin_access() ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'gatewell' ) ); } // Include settings page template. $template_path = GATEWELL_PLUGIN_DIR . 'includes/admin/views/settings-page.php'; if ( file_exists( $template_path ) ) { include $template_path; } } /** * Render the Metadata Scanner page. * * @since 1.5.3 */ public function render_metadata_scanner_page() { if ( ! $this->user_has_plugin_access() ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'gatewell' ) ); } $template_path = GATEWELL_PLUGIN_DIR . 'includes/admin/views/metadata-scanner-page.php'; if ( file_exists( $template_path ) ) { include $template_path; } } /** * Render the Dashboard page. * * @since 1.5.3 */ public function render_dashboard_page() { if ( ! $this->user_has_plugin_access() ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'gatewell' ) ); } $template_path = GATEWELL_PLUGIN_DIR . 'includes/admin/views/dashboard-page.php'; if ( file_exists( $template_path ) ) { include $template_path; } } /** * Render the System Info page. * * @since 1.5.3 */ public function render_system_info_page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'gatewell' ) ); } $template_path = GATEWELL_PLUGIN_DIR . 'includes/admin/views/system-info-page.php'; if ( file_exists( $template_path ) ) { include $template_path; } } /** * Enqueue admin assets (CSS and JS). * * @param string $hook The current admin page hook. * @since 1.0.0 */ public function enqueue_admin_assets( $hook ) { // Only load on our admin pages. if ( strpos( $hook, 'gatewell' ) === false ) { return; } // Enqueue admin CSS. wp_enqueue_style( 'gatewell-admin-settings', GATEWELL_PLUGIN_URL . 'assets/admin/css/admin-settings.css', array(), GATEWELL_VERSION ); // Enqueue admin JS (vanilla JavaScript, no dependencies). wp_enqueue_script( 'gatewell-admin-settings', GATEWELL_PLUGIN_URL . 'assets/admin/js/admin-settings.js', array(), GATEWELL_VERSION, true ); // Use URL hash to restore the active tab (e.g. after save redirect). // No PHP-driven initial tab needed — nav-tab-active is set in HTML. $initial_tab = ''; // Pass plugin URL, initial tab, and nonces to JavaScript. $localize_data = array( 'pluginUrl' => GATEWELL_PLUGIN_URL, 'initialTab' => $initial_tab, 'ajaxUrl' => admin_url( 'admin-ajax.php' ), ); wp_localize_script( 'gatewell-admin-settings', 'gatewellAdminSettings', $localize_data ); // Enqueue metadata scanner assets $this->enqueue_metadata_scanner_assets(); // Enqueue WordPress media library for compression preview image selection. wp_enqueue_media(); // Enqueue inline scripts for settings page interactions. $this->enqueue_settings_inline_scripts(); } /** * Enqueue inline scripts for settings page interactions. * * Moves inline