16 ) { return false; } $method = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Compare the sanitized method with its original unslashed bytes only to reject rewriting; no raw value is stored, displayed or accepted as a method. return 'POST' === $method && $method === wp_unslash( $_SERVER['REQUEST_METHOD'] ); } public static function register(): void { add_action( 'admin_post_antivirus_ai_setup', array( self::class, 'handle' ) ); add_action( 'admin_enqueue_scripts', array( self::class, 'assets' ) ); add_action( 'admin_notices', array( self::class, 'welcomeNotice' ) ); } /** A one-time invitation after this plugin's activation, never a redirect. */ public static function welcomeNotice(): void { if ( ! current_user_can( 'manage_options' ) || 'plugins' !== ( get_current_screen()->id ?? '' ) || ! self::needed() ) { return; } $key = 'antivirus_ai_setup_welcome_' . get_current_user_id(); if ( ! get_transient( $key ) ) { return; } delete_transient( $key ); echo '

' . esc_html__( 'Shieldlume Security is installed. Make it yours.', 'shieldlume-security' ) . '

' . esc_html__( 'Check local readiness, choose your permissions, and start your first scan when you are ready.', 'shieldlume-security' ) . '

' . esc_html__( 'Start setup', 'shieldlume-security' ) . ' ' . esc_html__( 'Open dashboard', 'shieldlume-security' ) . '

'; } public static function assets( string $hook ): void { if ( 'toplevel_page_antivirus-ai' === $hook ) { wp_enqueue_script( 'antivirus-ai-onboarding', plugins_url( 'assets/onboarding.js', ANTIVIRUS_AI_FILE ), array(), ANTIVIRUS_AI_VERSION, true ); } } public static function needed(): bool { $state = get_option( 'antivirus_ai_onboarding', array() ); return ! is_array( $state ) || self::VERSION !== ( $state['version'] ?? null ) || ! in_array( $state['status'] ?? '', array( 'completed', 'skipped' ), true ); } /** Identifiers are sanitized and must already use their canonical spelling. */ private static function key( array $values, string $name ): string { if ( ! isset( $values[ $name ] ) || ! is_string( $values[ $name ] ) || strlen( $values[ $name ] ) > 128 ) { return ''; } $value = wp_unslash( $values[ $name ] ); $clean = sanitize_key( $value ); return strlen( $clean ) <= 64 && $clean === $value && $clean === $values[ $name ] ? $clean : ''; } private static function state( string $status ): void { Store::saveOption( 'antivirus_ai_onboarding', array( 'version' => self::VERSION, 'status' => $status, 'updated_at' => time() ) ); } /** Only this POST boundary calls the private persistence/scan methods. */ public static function handle(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access is required.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } if ( ! self::postRequest() ) { wp_die( esc_html__( 'A POST request is required.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- The original nonce is read only for type and byte-length guards; sanitize_text_field(wp_unslash()) runs here before the standalone verifier and any requested operation. $nonce = isset( $_POST['_avai_nonce'] ) && is_string( $_POST['_avai_nonce'] ) && strlen( $_POST['_avai_nonce'] ) <= 40 ? sanitize_text_field( wp_unslash( $_POST['_avai_nonce'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Original nonce bytes are used only to reject sanitizer changes; only the bounded sanitized nonce reaches the following standalone verifier. if ( '' === $nonce || strlen( $nonce ) > 20 || $nonce !== wp_unslash( $_POST['_avai_nonce'] ) ) { wp_die( esc_html__( 'Refresh this form and try again.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } if ( ! wp_verify_nonce( $nonce, 'antivirus_ai_setup' ) ) { wp_die( esc_html__( 'Refresh this form and try again.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); return; } $operation = self::key( $_POST, 'setup_operation' ); if ( ! in_array( $operation, array( 'finish', 'finish_scan', 'skip' ), true ) ) { wp_die( esc_html__( 'Choose a valid setup action.', 'shieldlume-security' ), '', array( 'response' => 400 ) ); return; } $redirect = Admin::url( 'overview' ); $kind = 'success'; try { if ( 'skip' === $operation ) { self::state( 'skipped' ); $message = __( 'Setup skipped. Your saved preferences are unchanged. You can reopen Setup at any time.', 'shieldlume-security' ); } else { $message = self::finish( $_POST, 'finish_scan' === $operation ); $redirect = Admin::url( 'setup', array( 'setup_step' => 'welcome' ) ); } delete_transient( 'antivirus_ai_setup_form_' . get_current_user_id() ); } catch ( \Throwable $error ) { $kind = 'error'; $message = substr( $error->getMessage(), 0, 1000 ); $redirect = Admin::url( 'setup' ); // Keep only this administrator's bounded form choices, never arbitrary POST data. $values = array(); if ( 'skip' !== $operation ) { foreach ( self::FLAGS as $key ) { $values[ $key ] = '1' === self::key( $_POST, $key ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only the original email byte length is checked before decoding; the saved correction value uses wp_unslash and sanitize_email and is capped at 254 bytes. $values['email_to'] = isset( $_POST['email_to'] ) && is_string( $_POST['email_to'] ) && strlen( $_POST['email_to'] ) <= 512 ? substr( sanitize_email( wp_unslash( $_POST['email_to'] ) ), 0, 254 ) : ''; } set_transient( 'antivirus_ai_setup_form_' . get_current_user_id(), array( 'values' => $values, 'message' => $message ), 600 ); } set_transient( 'antivirus_ai_notice_' . get_current_user_id(), array( 'kind' => $kind, 'message' => $message ), 120 ); wp_safe_redirect( $redirect ); exit; } private static function finish( array $input, bool $scan ): string { if ( '1' !== self::key( $input, 'setup_preferences' ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'The setup form is incomplete. Reload Setup and review your choices.', 'shieldlume-security' ) ); } $old = Store::freshOption( 'antivirus_ai_settings', array() ); if ( ! is_array( $old ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'Saved preferences could not be read safely. Resolve the settings error before completing setup.', 'shieldlume-security' ) ); } $settings = array(); foreach ( self::FLAGS as $key ) { if ( isset( $input[ $key ] ) && ( ! is_string( $input[ $key ] ) || '1' !== self::key( $input, $key ) ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'A preference contains an invalid value. Reload Setup and try again.', 'shieldlume-security' ) ); } $settings[ $key ] = '1' === self::key( $input, $key ); } $email = isset( $input['email_to'] ) && is_string( $input['email_to'] ) && strlen( $input['email_to'] ) <= 512 ? sanitize_email( trim( wp_unslash( $input['email_to'] ) ) ) : ''; if ( ( isset( $input['email_to'] ) && ( ! is_string( $input['email_to'] ) || strlen( $input['email_to'] ) > 512 || $email !== trim( wp_unslash( $input['email_to'] ) ) ) ) || strlen( $email ) > 254 || ( '' !== $email && ! is_email( $email ) ) || ( $settings['email'] && '' === $email ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'Enter a valid notification address, or turn email alerts off and clear the address.', 'shieldlume-security' ) ); } if ( $settings['login_limit'] && LoginLimiter::conflicts() ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'Another security plugin provides overlapping login protection. Leave local login rate limiting off until that overlap is resolved.', 'shieldlume-security' ) ); } $settings['email_to'] = $email; $settings['telemetry'] = false; $changes = $settings; $settings = FeedConnection::saveSettings( static function ( array $current ) use ( $changes, &$old ): array { $old = $current; return array_replace( $current, $changes ); } ); try { self::state( 'completed' ); } catch ( \Throwable $error ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'Your preferences were saved, but setup completion could not be recorded. No scan was started or schedule changed. Retry after checking database access.', 'shieldlume-security' ), 0, $error ); } try { if ( (bool) ( $old['daily'] ?? false ) !== $settings['daily'] || ( $settings['daily'] && ! wp_next_scheduled( 'antivirus_ai_daily' ) ) ) { Scheduler::settingsChanged( $settings ); } if ( ! $settings['email'] ) { wp_clear_scheduled_hook( 'antivirus_ai_notify' ); } do_action( 'antivirus_ai_settings_saved', $settings ); if ( $scan ) { $id = ( new Scanner( new Store() ) )->start(); Scheduler::scheduleTick( $id ); return __( 'Setup saved. Your scan is queued or already active. Review actual progress and coverage in Dashboard; use Continue scanning if WP-Cron does not resume it.', 'shieldlume-security' ); } } catch ( \Throwable $error ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'Setup and preferences were saved, but follow-up work did not finish. Review Dashboard and Protection settings before retrying: ', 'shieldlume-security' ) . substr( $error->getMessage(), 0, 500 ), 0, $error ); } return __( 'Setup saved. No manual scan was started. Any schedule you enabled will request future scans; review its actual progress in Dashboard.', 'shieldlume-security' ); } private static function choices(): array { return array( 'daily' => array( __( 'Schedule regular scans', 'shieldlume-security' ), __( 'Check daily using WordPress scheduling. Site traffic and your host affect timing; review completed scans in Dashboard.', 'shieldlume-security' ) ), 'login_limit' => array( __( 'Limit repeated login attempts', 'shieldlume-security' ), __( 'Slow repeated failed sign-ins. Shared networks can affect legitimate users; overlapping security plugins suspend this protection.', 'shieldlume-security' ), __( 'The limits are 10 failures per account or 30 per network in 15 minutes. Local salted account and network identifiers expire.', 'shieldlume-security' ) ), 'online_references' => array( __( 'Check files against official originals', 'shieldlume-security' ), __( 'During scans, request official core/plugin checksums and inspect exact-version theme ZIPs locally without extracting or running their code. WordPress.org receives your network address, request timing, requested component/version and core locale, with a fixed Shieldlume user-agent. Pro can also retrieve originals for separately approved repairs. Custom or premium files without a trusted original stay unverified.', 'shieldlume-security' ) ), 'vulnerability_feed' => array( __( 'Check for known software vulnerabilities', 'shieldlume-security' ), __( 'During scans, download the Wordfence Intelligence feed. Wordfence receives your permitted key and network address; matching installed versions happens on your site. Connect a feed key in Protection after setup. Results show missing or outdated coverage.', 'shieldlume-security' ) ), 'email' => array( __( 'Send critical alerts and scan digests', 'shieldlume-security' ), __( 'Email essential findings to the address below through your site’s mail service. That service receives the address and message. A scan digest is queued when a scan ends, at most once per UTC day; no timed daily email is scheduled. Send attempts are recorded; delivery to your inbox cannot be guaranteed.', 'shieldlume-security' ) ), ); } private static function choice( string $key, array $settings ): void { $choice = self::choices()[ $key ]; [$label, $description] = $choice; echo ''; if ( ! empty( $choice[2] ) ) { echo '
' . esc_html__( 'How the sign-in limit works', 'shieldlume-security' ) . '

' . esc_html( $choice[2] ) . '

'; } } private static function formStart(): void { echo '
'; wp_nonce_field( 'antivirus_ai_setup', '_avai_nonce' ); } private static function heading( int $step, string $eyebrow, string $title, string $description ): void { echo '

' . esc_html( $eyebrow ) . '

' . esc_html( $title ) . '

' . esc_html( $description ) . '

'; } private static function navigation( bool $back = true ): void { echo ''; } /** The optional final page has its own form after preferences have been saved. */ public static function progress( int $current = 0 ): void { echo '
    '; foreach ( array( __( 'Welcome', 'shieldlume-security' ), __( 'Readiness', 'shieldlume-security' ), __( 'Preferences', 'shieldlume-security' ), __( 'Review', 'shieldlume-security' ), __( 'Optional extras', 'shieldlume-security' ) ) as $index => $label ) { echo '
  1. ' . esc_html( $label ) . '
  2. '; } echo '
'; } public static function render(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Administrator access is required.', 'shieldlume-security' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Fixed read-only presentation route; completion is only written by the nonce-protected setup POST handler. $final = 'welcome' === self::key( $_GET, 'setup_step' ); $state = get_option( 'antivirus_ai_onboarding', array() ); if ( $final && is_array( $state ) && self::VERSION === ( $state['version'] ?? null ) && 'completed' === ( $state['status'] ?? '' ) ) { Welcome::renderSetup(); return; } echo '

' . esc_html__( 'Setup guide', 'shieldlume-security' ) . '

'; $settings = Store::freshOption( 'antivirus_ai_settings', array() ); if ( ! is_array( $settings ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is internal data, not HTML output; UI consumers escape at their output context. Escaping here would corrupt JSON/plain-text evidence and chained exception arguments. throw new \RuntimeException( __( 'Saved preferences could not be read. Restore settings access before continuing setup.', 'shieldlume-security' ) ); } $error = get_transient( 'antivirus_ai_setup_form_' . get_current_user_id() ); if ( is_array( $error ) ) { delete_transient( 'antivirus_ai_setup_form_' . get_current_user_id() ); $settings = array_merge( $settings, (array) ( $error['values'] ?? array() ) ); } $checks = Diagnostics::checks( new Store() ); echo '
'; self::progress(); if ( is_array( $error ) ) { echo ''; } self::formStart(); self::heading( 0, __( 'YOUR SITE. YOUR CHOICES.', 'shieldlume-security' ), __( 'Set up your first scan.', 'shieldlume-security' ), __( 'Check this site, choose your preferences, then save or start a scan. You can skip setup at any time.', 'shieldlume-security' ) ); echo '
' . esc_html__( '01 · Look', 'shieldlume-security' ) . '

' . esc_html__( 'Understand readiness', 'shieldlume-security' ) . '

' . esc_html__( 'See what this installation can do now and what still needs attention.', 'shieldlume-security' ) . '

' . esc_html__( '02 · Choose', 'shieldlume-security' ) . '

' . esc_html__( 'Control the connections', 'shieldlume-security' ) . '

' . esc_html__( 'Every optional permission starts off on a new installation. Existing choices are carried forward.', 'shieldlume-security' ) . '

' . esc_html__( '03 · Review', 'shieldlume-security' ) . '

' . esc_html__( 'Act on evidence', 'shieldlume-security' ) . '

' . esc_html__( 'Scans report coverage and findings. Review the evidence before choosing your next step.', 'shieldlume-security' ) . '

'; echo '

' . esc_html__( 'Opening this guide does not scan your site or contact an external service. Setup is optional; reopen Setup guide whenever you need it.', 'shieldlume-security' ) . '

'; self::navigation( false ); echo '
'; self::heading( 1, __( 'LOCAL READINESS', 'shieldlume-security' ), __( 'Can this site run a scan?', 'shieldlume-security' ), __( 'Check file access and storage first. Optional connections and Pro recovery can be set up later.', 'shieldlume-security' ) ); $statuses = array( 'ready' => __( 'Ready', 'shieldlume-security' ), 'action_required' => __( 'Needs review', 'shieldlume-security' ), 'optional_off' => __( 'Optional · off', 'shieldlume-security' ), 'unverified' => __( 'Not verified', 'shieldlume-security' ), 'temporary_grace' => __( 'Temporary grace', 'shieldlume-security' ) ); $essential = array(); $optional = array(); foreach ( $checks as $check ) { if ( in_array( $check['check'], array( 'filesystem_access', 'disk_space', 'plugin_compatibility' ), true ) ) { $essential[] = $check; } else { $optional[] = $check; } } foreach ( array( $essential, $optional ) as $group => $observations ) { if ( 1 === $group ) { echo '
' . esc_html__( 'Optional Pro and service setup', 'shieldlume-security' ) . ' ' . count( $observations ) . '

' . esc_html__( 'Protected recovery, independent workers and connected services may need extra setup. They are separate from local scanning.', 'shieldlume-security' ) . '

'; } echo ''; if ( 1 === $group ) { echo '
'; } } self::navigation(); echo ''; self::heading( 2, __( 'PERMISSION BY PERMISSION', 'shieldlume-security' ), __( 'Choose your scan settings.', 'shieldlume-security' ), __( 'Keep the choices you want. They take effect when you save; returning here shows your saved settings.', 'shieldlume-security' ) ); echo '

' . esc_html__( 'Your everyday routine', 'shieldlume-security' ) . '

' . esc_html__( 'Choose local scheduling, sign-in limits and email alerts first. Email uses your site’s existing mail service.', 'shieldlume-security' ) . '

'; foreach ( array( 'daily', 'login_limit', 'email' ) as $key ) { self::choice( $key, $settings ); } echo '

' . esc_html__( 'Required only when email alerts are enabled. No test message is sent by setup.', 'shieldlume-security' ) . '

'; $online = ! empty( $settings['online_references'] ) || ! empty( $settings['vulnerability_feed'] ); echo '
' . esc_html__( 'Optional online checks', 'shieldlume-security' ) . ' · ' . esc_html( $online ? __( 'Review saved permissions', 'shieldlume-security' ) : __( 'Off unless selected', 'shieldlume-security' ) ) . '

' . esc_html__( 'Local scanning works with these checks off. Each checkbox permits only the service described below. To stop a saved permission, clear its checkbox and save.', 'shieldlume-security' ) . '

'; foreach ( array( 'online_references', 'vulnerability_feed' ) as $key ) { self::choice( $key, $settings ); } echo '

' . esc_html__( 'Feed access needs a permitted token; connect it in Protection', 'shieldlume-security' ) . '

'; echo '

' . esc_html__( 'You can add two-step sign-in, request protection and a vulnerability feed later in Protection.', 'shieldlume-security' ) . '

'; echo '

' . esc_html__( 'Optional email tips and compatibility sharing come last, after your scan settings are saved. Pro service permissions are separate.', 'shieldlume-security' ) . '

'; self::navigation(); echo ''; self::heading( 3, __( 'READY WHEN YOU ARE', 'shieldlume-security' ), __( 'Save your choices.', 'shieldlume-security' ), __( 'Save and continue to the final optional page, or save and start a scan too. Review scan progress in Dashboard.', 'shieldlume-security' ) ); echo '

' . esc_html__( 'Review the preferences above before finishing. Only the boxes you selected will be enabled.', 'shieldlume-security' ) . '

' . esc_html__( 'A scan uses the optional online checks you permit. It does not repair files automatically. Any schedule you enable requests future scans even if you continue without starting one now.', 'shieldlume-security' ) . '

'; echo '
'; wp_nonce_field( 'antivirus_ai_setup', '_avai_nonce' ); echo '' . esc_html__( 'Saved preferences stay as they are.', 'shieldlume-security' ) . '
'; } }