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'] ); } /** Identifiers are sanitized and must already use their canonical spelling. */ private static function requestKey( 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 : ''; } public static function register(): void { add_action( 'admin_post_antivirus_ai_journey', array( self::class, 'handle' ) ); } public static function registerPrivacy(): void { add_filter( 'wp_privacy_personal_data_exporters', static function ( $exporters ) { $exporters['shieldlume-journey'] = array( 'exporter_friendly_name' => __( 'Shieldlume local task and preview choices', 'shieldlume-security' ), 'callback' => array( self::class, 'privacyExport' ) ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['shieldlume-journey'] = array( 'eraser_friendly_name' => __( 'Shieldlume local task and preview choices', 'shieldlume-security' ), 'callback' => array( self::class, 'privacyErase' ) ); return $erasers; } ); } private static function authorized(): bool { return ! is_multisite() && get_current_user_id() > 0 && current_user_can( 'manage_options' ); } private static function authority(): void { if ( ! self::authorized() ) { throw new \RuntimeException( 'Current single-site administrator authority is required.' ); } } private static function key(): string { return '_antivirus_ai_journey_' . get_current_blog_id(); } private static function intentKey(): string { return 'antivirus_ai_task_' . get_current_blog_id() . '_' . get_current_user_id(); } private static function site(): string { return hash( 'sha256', get_current_blog_id() . '|' . home_url( '/' ) ); } /** All task/preference mutations share this actor/site lock. Reload WordPress caches only after acquiring it. */ private static function locked( Store $store, callable $action ): mixed { self::authority(); $actor = get_current_user_id(); $site = self::site(); $name = 'journey_' . substr( hash( 'sha256', $site . '|' . $actor ), 0, 48 ); $owner = $store->lock( $name, 120 ); if ( ! $owner ) { throw new \RuntimeException( 'Another request is updating your saved task or suggestion preference. Retry shortly; this request changed nothing.' ); } try { self::authority(); if ( $actor !== get_current_user_id() || ! hash_equals( $site, self::site() ) ) { throw new \RuntimeException( 'Your administrator or site context changed. Reload before continuing.' ); } wp_cache_delete( $actor, 'user_meta' ); self::intent( true ); return $action(); } finally { $store->unlock( $name, $owner ); } } private static function data(): array { $value = get_user_meta( get_current_user_id(), self::key(), true ); return is_array( $value ) && ( $value['site'] ?? '' ) === self::site() ? $value : array( 'site' => self::site() ); } private static function save( array $value ): void { self::authority(); $value['site'] = self::site(); if ( false === update_user_meta( get_current_user_id(), self::key(), $value ) && get_user_meta( get_current_user_id(), self::key(), true ) !== $value ) { throw new \RuntimeException( 'Local task preference could not be saved. Retry after checking database access.' ); } } public static function proInstalled(): bool { return defined( 'ANTIVIRUS_AI_PRO_VERSION' ) && function_exists( 'antivirus_ai_pro_compatible' ) && antivirus_ai_pro_compatible() && class_exists( '\AntivirusAI\Pro\Workspace' ); } public static function proUnlocked(): bool { return self::proInstalled() && class_exists( '\AntivirusAI\Pro\ProAccess' ) && \AntivirusAI\Pro\ProAccess::allowed(); } public static function previewsHidden(): bool { return ! self::authorized() || ! empty( self::data()['previews_hidden'] ); } public static function suggestionHidden( string $workflow ): bool { if ( self::previewsHidden() || ! self::workflow( $workflow ) || self::proUnlocked() ) { return true; } $until = self::data()['dismissals'][ $workflow ] ?? 0; return -1 === $until || ( is_int( $until ) && $until > time() ); } /** A slow activation response must not resume a replaced or forgotten task. */ public static function currentFresh( ?Store $store = null ): ?array { if ( ! self::authorized() ) { return null; } return self::current( $store, true ); } private static function intent( bool $fresh = false ): mixed { // In an external object cache, this entry is the stored task itself, not a disposable copy. if ( $fresh && wp_using_ext_object_cache() ) { return wp_cache_get( self::intentKey(), 'transient', true ); } if ( ! $fresh ) { return get_transient( self::intentKey() ); } wp_cache_delete( '_transient_' . self::intentKey(), 'options' ); wp_cache_delete( '_transient_timeout_' . self::intentKey(), 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); return get_transient( self::intentKey() ); } /** Privacy reads retained storage without refreshing a task lifetime or reviving an expired reminder. */ private static function privacyTask( string $key ): mixed { if ( wp_using_ext_object_cache() ) { return wp_cache_get( $key, 'transient', true ); } wp_cache_delete( '_transient_' . $key, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); return get_option( '_transient_' . $key, false ); } private static function privacyIdentity( int $actor, string $email, int $blog, string $site ): void { clean_user_cache( $actor ); $user = get_user_by( 'email', $email ); if ( ! $user || $actor !== (int) $user->ID || $blog !== get_current_blog_id() || ! hash_equals( $site, self::site() ) ) { throw new \RuntimeException( 'The privacy request identity or site changed.' ); } } /** Core resolves and authorizes privacy requests; the requested user need not be the current administrator. */ public static function privacyExport( string $email, int $page = 1 ): array { $user = 1 === $page ? get_user_by( 'email', $email ) : false; $data = array(); if ( $user ) { $actor = (int) $user->ID; $blog = get_current_blog_id(); $site = self::site(); $fields = array(); wp_cache_delete( $actor, 'user_meta' ); $value = get_user_meta( $actor, self::key(), true ); if ( is_array( $value ) && ( $value['site'] ?? '' ) === $site ) { if ( isset( $value['previews_hidden'] ) && is_bool( $value['previews_hidden'] ) ) { $fields[] = array( 'name' => __( 'Pro previews hidden', 'shieldlume-security' ), 'value' => $value['previews_hidden'] ? __( 'Yes', 'shieldlume-security' ) : __( 'No', 'shieldlume-security' ) ); } foreach ( self::FLOWS as $flow ) { $until = is_array( $value['dismissals'] ?? null ) ? ( $value['dismissals'][ $flow ] ?? null ) : null; if ( is_int( $until ) && ( -1 === $until || $until > 0 ) ) { $fields[] = array( 'name' => __( 'Suggestion dismissal', 'shieldlume-security' ) . ': ' . $flow, 'value' => -1 === $until ? __( 'Never show', 'shieldlume-security' ) : gmdate( 'c', $until ) ); } } } $task = self::privacyTask( 'antivirus_ai_task_' . $blog . '_' . $actor ); if ( is_array( $task ) && ( $task['site'] ?? '' ) === $site && ( $task['actor'] ?? null ) === $actor && self::workflow( $task['workflow'] ?? null ) && is_int( $task['created'] ?? null ) && is_int( $task['expires'] ?? null ) && $task['created'] > 0 && $task['created'] <= time() && $task['expires'] > $task['created'] && $task['expires'] <= $task['created'] + self::LIFETIME ) { $fields[] = array( 'name' => __( 'Saved task workflow', 'shieldlume-security' ), 'value' => $task['workflow'] ); $fields[] = array( 'name' => __( 'Task saved', 'shieldlume-security' ), 'value' => gmdate( 'c', $task['created'] ) ); $fields[] = array( 'name' => __( 'Task expires', 'shieldlume-security' ), 'value' => gmdate( 'c', $task['expires'] ) ); $fields[] = array( 'name' => __( 'Task status', 'shieldlume-security' ), 'value' => $task['expires'] <= time() ? __( 'Expired', 'shieldlume-security' ) : __( 'Saved', 'shieldlume-security' ) ); if ( 'scheduling' === $task['workflow'] && in_array( $task['interval'] ?? null, array( 900, 3600, 21600, 86400 ), true ) ) { $fields[] = array( 'name' => __( 'Requested interval in seconds', 'shieldlume-security' ), 'value' => (string) $task['interval'] ); } } try { self::privacyIdentity( $actor, $email, $blog, $site ); } catch ( \Throwable $error ) { $fields = array(); } if ( $fields ) { $data[] = array( 'group_id' => 'shieldlume-journey', 'group_label' => __( 'Shieldlume local task and preview choices', 'shieldlume-security' ), 'item_id' => 'shieldlume-journey-' . $blog, 'data' => $fields ); } } return array( 'data' => $data, 'done' => true ); } public static function privacyErase( string $email, int $page = 1 ): array { $user = 1 === $page ? get_user_by( 'email', $email ) : false; $removed = false; $retained = false; if ( $user ) { $actor = (int) $user->ID; $blog = get_current_blog_id(); $site = self::site(); $key = self::key(); $taskKey = 'antivirus_ai_task_' . $blog . '_' . $actor; $name = 'journey_' . substr( hash( 'sha256', $site . '|' . $actor ), 0, 48 ); $store = new Store(); $owner = null; try { $owner = $store->lock( $name, 120 ); if ( ! $owner ) { throw new \RuntimeException( 'Local task choices are busy.' ); } $guard = static function () use ( $store, $name, $owner, $actor, $email, $blog, $site ): void { DataRemoval::assertWritable(); self::privacyIdentity( $actor, $email, $blog, $site ); if ( ! $store->ownsLock( $name, $owner ) ) { throw new \RuntimeException( 'The local task lock changed or expired.' ); } }; $guard(); wp_cache_delete( $actor, 'user_meta' ); $value = get_user_meta( $actor, $key, true ); $task = self::privacyTask( $taskKey ); if ( '' !== $value ) { if ( ! is_array( $value ) || ( $value['site'] ?? '' ) !== $site ) { $retained = true; } else { $guard(); $deleted = delete_user_meta( $actor, $key, $value ); wp_cache_delete( $actor, 'user_meta' ); $guard(); if ( ! $deleted || '' !== get_user_meta( $actor, $key, true ) ) { $retained = true; } else { $removed = true; } } } if ( false !== $task ) { if ( ! is_array( $task ) || ( $task['site'] ?? '' ) !== $site || ( $task['actor'] ?? null ) !== $actor ) { $retained = true; } else { $guard(); if ( self::privacyTask( $taskKey ) !== $task ) { throw new \RuntimeException( 'The saved task changed.' ); } $deleted = delete_transient( $taskKey ); $guard(); if ( ! $deleted || false !== self::privacyTask( $taskKey ) ) { $retained = true; } else { $removed = true; } } } } catch ( \Throwable $error ) { $retained = true; } finally { if ( $owner ) { $store->unlock( $name, $owner ); } } } $messages = array(); if ( $removed ) { $messages[] = __( 'Local task and preview choices were erased. Clearing hide and dismissal choices may show optional previews and suggestions again. Security evidence and service permissions were not changed.', 'shieldlume-security' ); } if ( $retained ) { $messages[] = __( 'Some local task or preview choices could not be safely erased because their ownership, site, lock or storage changed or could not be verified. Review the current site and database or object cache, then retry.', 'shieldlume-security' ); } return array( 'items_removed' => $removed, 'items_retained' => $retained, 'messages' => $messages, 'done' => true ); } private static function workflow( $value ): string { return is_string( $value ) && in_array( $value, self::FLOWS, true ) ? $value : ''; } private static function ids( $value, int $maximum ): array { if ( ! is_array( $value ) || count( $value ) > $maximum || count( array_filter( $value, 'is_string' ) ) !== count( $value ) ) { throw new \InvalidArgumentException( 'Choose a bounded set of record references.' ); } foreach ( $value as $index => $id ) { $clean = self::requestKey( array( 'id' => $id ), 'id' ); if ( ! preg_match( '/^[a-z0-9_]{1,64}$/D', $clean ) ) { throw new \InvalidArgumentException( 'Invalid local record reference.' ); } $value[ $index ] = $clean; } if ( count( array_unique( $value ) ) !== count( $value ) ) { throw new \InvalidArgumentException( 'Duplicate record references are not supported.' ); } return array_values( $value ); } private static function finding( Store $store, string $id ): ?array { $row = $store->get( $id ); return $row && 'finding' === ( $row['_kind'] ?? '' ) && in_array( $row['_status'] ?? '', array( 'open', 'acknowledged' ), true ) ? $row : null; } /** Read-only validation of existing Pro previews; never fetches references or creates a repair plan. */ private static function plan( Store $store, string $id ): ?array { if ( ! current_user_can( 'edit_plugins' ) ) { return null; } $plan = $store->get( $id ); if ( ! $plan || 'repair' !== ( $plan['_kind'] ?? '' ) || 'planned' !== ( $plan['_status'] ?? '' ) || (int) ( $plan['actor'] ?? 0 ) !== get_current_user_id() || (int) ( $plan['expires'] ?? 0 ) <= time() || ! empty( $plan['authorization'] ) || ! empty( $plan['rollback_of'] ) ) { return null; } $finding = self::finding( $store, (string) ( $plan['finding_id'] ?? '' ) ); if ( ! $finding || empty( $finding['action_eligibility']['repair'] ) || ( $finding['content_hash'] ?? '' ) !== ( $plan['expected_hash'] ?? null ) ) { return null; } if ( ( $finding['action_eligibility']['reference'] ?? null ) !== ( $plan['reference'] ?? array() ) ) { return null; } if ( 'theme' === ( $plan['reference']['type'] ?? '' ) && ! current_user_can( 'edit_themes' ) ) { return null; } try { $bytes = PathGuard::read( (string) ( $plan['root'] ?? '' ), (string) ( $plan['path'] ?? '' ) ); if ( ! hash_equals( (string) ( $plan['expected_hash'] ?? '' ), $bytes['hash'] ) ) { return null; } } catch ( \Throwable $error ) { return null; } return $plan; } /** Read-only. Revalidates actor, site, expiry and references on every call. Never returns approvals, paths or secrets. */ public static function current( ?Store $store = null, bool $fresh = false ): ?array { if ( ! self::authorized() ) { return null; } $raw = self::intent( $fresh ); if ( ! is_array( $raw ) || ! self::workflow( $raw['workflow'] ?? null ) || ! is_string( $raw['id'] ?? null ) || ! preg_match( '/^[a-f0-9]{32}$/D', $raw['id'] ) || (int) ( $raw['actor'] ?? 0 ) !== get_current_user_id() || ( $raw['site'] ?? '' ) !== self::site() || (int) ( $raw['expires'] ?? 0 ) <= time() || (int) ( $raw['expires'] ?? 0 ) > (int) ( $raw['created'] ?? 0 ) + self::LIFETIME || (int) ( $raw['created'] ?? 0 ) > time() ) { return null; } if ( 'batches' === $raw['workflow'] && ! current_user_can( 'edit_plugins' ) ) { return null; } try { $findings = self::ids( $raw['finding_ids'] ?? array(), 20 ); $plans = self::ids( $raw['plan_ids'] ?? array(), 5 ); } catch ( \Throwable $error ) { return null; } $store = $store ?? new Store(); $currentFindings = array_values( array_filter( $findings, static fn( $id ) => null !== self::finding( $store, $id ) ) ); $currentPlans = array(); $targets = array(); foreach ( $plans as $id ) { $plan = self::plan( $store, $id ); if ( ! $plan || ! in_array( $plan['finding_id'], $currentFindings, true ) ) { continue; } $target = $plan['root'] . '/' . $plan['path']; if ( isset( $targets[ $target ] ) ) { continue; } $targets[ $target ] = true; $currentPlans[] = $id; } $interval = (int) ( $raw['interval'] ?? 0 ); return array( 'id' => $raw['id'], 'workflow' => $raw['workflow'], 'actor' => get_current_user_id(), 'site' => self::site(), 'created' => (int) $raw['created'], 'expires' => (int) $raw['expires'], 'finding_ids' => $currentFindings, 'plan_ids' => $currentPlans, 'interval' => in_array( $interval, array( 900, 3600, 21600, 86400 ), true ) ? $interval : 0, 'references_changed' => count( $findings ) !== count( $currentFindings ), 'needs_new_previews' => 'batches' === $raw['workflow'] && count( $plans ) !== count( $currentPlans ) ); } /** Called only from an authorized explicit POST; expected id prevents an old action clearing a new task. */ public static function complete( string $workflow, string $expectedId ): bool { self::authority(); if ( ! self::postRequest() ) { throw new \RuntimeException( 'A POST request is required.' ); } return self::locked( new Store(), static fn() => self::clearLocked( $workflow, $expectedId ) ); } private static function clearLocked( string $workflow, string $expectedId ): bool { $intent = self::intent( true ); if ( ! is_array( $intent ) || ( $intent['site'] ?? '' ) !== self::site() || (int) ( $intent['actor'] ?? 0 ) !== get_current_user_id() ) { return false; } if ( ! self::workflow( $workflow ) || ( $intent['workflow'] ?? '' ) !== $workflow || ! is_string( $intent['id'] ?? null ) || ! hash_equals( $intent['id'], $expectedId ) ) { return false; } delete_transient( self::intentKey() ); return true; } public static function destination( string $workflow ): string { if ( ! self::workflow( $workflow ) ) { return Admin::url( 'pro' ); } if ( self::proInstalled() ) { if ( ! self::proUnlocked() ) { return \AntivirusAI\Pro\Workspace::url( 'connection' ); } if ( 'reports' === $workflow ) { return \AntivirusAI\Pro\Workspace::url( 'reports' ); } if ( 'ai' === $workflow ) { $intent = self::currentFresh(); $id = $intent && 'ai' === $intent['workflow'] ? ( $intent['finding_ids'][0] ?? '' ) : ''; return $id ? Admin::url( 'findings', array( 'finding' => $id, 'search' => $id ) ) . '#avai-guide-' . $id : Admin::url( 'findings' ); } return \AntivirusAI\Pro\Workspace::url( 'investigations' === $workflow ? 'incidents' : 'automation' ) . ( 'batches' === $workflow ? '#avai-reviewed-batches' : ( 'scheduling' === $workflow ? '#avai-scheduled-checks' : '' ) ); } return ProPreviews::url( $workflow ); } /** Request boundary shared by handler and deterministic tests. Unknown posted fields are never retained. */ public static function dispatch( array $input, Store $store ): string { self::authority(); if ( ! self::postRequest() ) { throw new \RuntimeException( 'A POST request is required.' ); } $operation = self::requestKey( $input, 'journey_operation' ); if ( ! in_array( $operation, array( 'remember', 'upgrade', 'dismiss', 'clear', 'preferences' ), true ) ) { throw new \InvalidArgumentException( 'Unknown local task action.' ); } $nonce = isset( $input['_avai_journey_nonce'] ) && is_string( $input['_avai_journey_nonce'] ) && strlen( $input['_avai_journey_nonce'] ) <= 40 ? sanitize_text_field( wp_unslash( $input['_avai_journey_nonce'] ) ) : ''; if ( '' === $nonce || strlen( $nonce ) > 20 || $nonce !== wp_unslash( $input['_avai_journey_nonce'] ) ) { throw new \UnexpectedValueException( 'Refresh this form and try again.' ); } if ( ! wp_verify_nonce( $nonce, 'antivirus_ai_journey_' . $operation ) ) { throw new \UnexpectedValueException( 'Refresh this form and try again.' ); } $workflow = self::workflow( self::requestKey( $input, 'workflow' ) ); if ( ! $workflow && 'preferences' !== $operation ) { throw new \InvalidArgumentException( 'Choose a supported workflow.' ); } $preview_return_tab = ''; if ( 'preferences' === $operation && array_key_exists( 'preview_return_tab', $input ) ) { $preview_return_tab = self::previewReturnTab( self::requestKey( $input, 'preview_return_tab' ) ); if ( '' === $preview_return_tab ) { throw new \InvalidArgumentException( 'Choose a valid Free page for the preview preference return.' ); } } return self::locked( $store, static fn() => self::mutateLocked( $input, $store, $operation, $workflow, $preview_return_tab ) ); } private static function mutateLocked( array $input, Store $store, string $operation, string $workflow, string $preview_return_tab = '' ): string { if ( 'preferences' === $operation ) { $choice = self::requestKey( $input, 'preview_choice' ); if ( ! in_array( $choice, array( 'hide', 'restore' ), true ) ) { throw new \InvalidArgumentException( 'Choose whether to hide or restore Pro previews.' ); } $data = self::data(); $data['previews_hidden'] = 'hide' === $choice; if ( 'restore' === $choice ) { unset( $data['dismissals'] ); } self::save( $data ); return $preview_return_tab ? Admin::url( $preview_return_tab ) : Admin::url( 'settings' ) . '#avai-pro-preview-preferences'; } if ( 'clear' === $operation ) { $id = self::requestKey( $input, 'context_id' ); if ( ! preg_match( '/^[a-f0-9]{32}$/D', $id ) ) { throw new \InvalidArgumentException( 'Invalid saved task reference.' ); } self::clearLocked( $workflow, $id ); return self::destination( $workflow ); } $data = self::data(); if ( 'dismiss' === $operation ) { $duration = self::requestKey( $input, 'duration' ); $permanent = 'never' === $duration; if ( ! $permanent && 'later' !== $duration ) { throw new \InvalidArgumentException( 'Choose a supported suggestion preference.' ); } $data['dismissals'][ $workflow ] = $permanent ? -1 : time() + 2592000; unset( $data['hint'] ); self::save( $data ); return Admin::url( 'scheduling' === $workflow ? 'settings' : ( 'reports' === $workflow ? 'activity' : 'findings' ) ); } $findings = self::ids( $input['finding_ids'] ?? array(), 20 ); $plans = self::ids( $input['plan_ids'] ?? array(), 5 ); if ( isset( $input['interval'] ) && ! is_string( $input['interval'] ) && ! is_int( $input['interval'] ) ) { throw new \InvalidArgumentException( 'Choose a scalar interval.' ); } $interval = isset( $input['interval'] ) && is_int( $input['interval'] ) ? (string) $input['interval'] : self::requestKey( $input, 'interval' ); if ( isset( $input['interval'] ) && '' !== $input['interval'] && '' === $interval ) { throw new \InvalidArgumentException( 'Choose a supported interval.' ); } if ( ! in_array( $interval, array( '', '0', '900', '3600', '21600', '86400' ), true ) ) { throw new \InvalidArgumentException( 'Choose a supported interval.' ); } if ( 'scheduling' === $workflow && ( $findings || $plans ) ) { throw new \InvalidArgumentException( 'Scheduling does not use finding or repair references.' ); } if ( 'investigations' === $workflow && $plans ) { throw new \InvalidArgumentException( 'Investigation context does not authorize repairs.' ); } if ( 'reports' === $workflow && ( $findings || $plans ) ) { throw new \InvalidArgumentException( 'Reports do not retain finding or repair references in task reminders.' ); } if ( 'ai' === $workflow && ( $plans || count( $findings ) > 1 ) ) { throw new \InvalidArgumentException( 'An explanation reminder can refer to one current finding only.' ); } $targets = array(); foreach ( $plans as $id ) { $plan = self::plan( $store, $id ); if ( ! $plan ) { throw new \RuntimeException( 'A replacement preview is stale or unavailable. Review current findings and create a fresh preview in Pro.' ); } $target = $plan['root'] . '/' . $plan['path']; if ( isset( $targets[ $target ] ) ) { throw new \RuntimeException( 'Choose one preview per file.' ); } $targets[ $target ] = true; if ( ! in_array( $plan['finding_id'], $findings, true ) ) { $findings[] = $plan['finding_id']; } } if ( 'batches' === $workflow && ! current_user_can( 'edit_plugins' ) ) { throw new \RuntimeException( 'Plugin-editing authority is required for replacement workflows.' ); } foreach ( $findings as $id ) { if ( ! self::finding( $store, $id ) ) { throw new \RuntimeException( 'A finding changed or is no longer available for review. Refresh the findings.' ); } } if ( count( $findings ) > 20 ) { throw new \RuntimeException( 'Choose at most twenty finding references.' ); } $intent = array( 'id' => bin2hex( random_bytes( 16 ) ), 'workflow' => $workflow, 'actor' => get_current_user_id(), 'site' => self::site(), 'created' => time(), 'expires' => time() + self::LIFETIME, 'finding_ids' => $findings, 'plan_ids' => $plans, 'interval' => 'scheduling' === $workflow ? (int) $interval : 0 ); if ( ! set_transient( self::intentKey(), $intent, self::LIFETIME ) ) { throw new \RuntimeException( 'Local task could not be saved. Retry after checking database access.' ); } unset( $data['hint'] ); self::save( $data ); return 'upgrade' === $operation && ! self::proInstalled() ? ProPreviews::purchaseUrl() : self::destination( $workflow ); } public static function handle(): void { if ( ! self::authorized() ) { wp_die( esc_html__( 'Administrator authority and a POST request are 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 -- Read-only workflow selection is allowlisted; dispatch verifies the nonce and actor before any saved change. try { $redirect = self::dispatch( $_POST, new Store() ); } catch ( \Throwable $error ) { wp_die( esc_html( $error->getMessage() ), '', array( 'response' => $error instanceof \UnexpectedValueException ? 403 : 400 ) ); return; } if ( ProPreviews::purchaseUrl() === $redirect ) { // This exact fixed HTTPS destination follows the administrator's nonce-verified Get Pro POST. No site, task, finding, referral or credential is appended. header( 'Referrer-Policy: no-referrer' ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Exact equality with the fixed publisher purchase URL above; never a request-supplied destination. wp_redirect( ProPreviews::purchaseUrl(), 303 ); exit; } wp_safe_redirect( $redirect, 303 ); exit; } private static function form( string $operation, string $workflow ): void { echo '
'; wp_nonce_field( 'antivirus_ai_journey_' . $operation, '_avai_journey_nonce' ); } public static function requestButton( string $workflow, string $label, array $findingIds = array(), array $planIds = array() ): void { if ( ! self::authorized() || ! self::workflow( $workflow ) ) { return; } if ( ! $findingIds && ! $planIds ) { echo '' . esc_html( $label ) . ''; return; } self::form( 'remember', $workflow ); foreach ( array( 'finding_ids' => $findingIds, 'plan_ids' => $planIds ) as $name => $ids ) { foreach ( $ids as $id ) { echo ''; } } submit_button( $label, 'secondary', 'submit', false ); echo '
'; } public static function renderContinuation(): void { $intent = self::current(); if ( ! $intent ) { return; } $labels = array( 'scheduling' => __( 'Continue scan scheduling', 'shieldlume-security' ), 'investigations' => __( 'Continue your investigation', 'shieldlume-security' ), 'batches' => __( 'Continue reviewed repairs', 'shieldlume-security' ), 'reports' => __( 'Continue report preparation', 'shieldlume-security' ), 'ai' => __( 'Continue finding explanation', 'shieldlume-security' ) ); echo '

' . esc_html__( 'Your saved task', 'shieldlume-security' ) . '

' . esc_html__( 'Saved only for your administrator account on this site for up to seven days. This is navigation context, not approval to scan, connect or repair.', 'shieldlume-security' ) . '

'; if ( $intent['needs_new_previews'] || $intent['references_changed'] ) { echo '

' . esc_html__( 'Some references have changed or expired. Review current findings and prepare fresh previews before continuing.', 'shieldlume-security' ) . '

'; } echo '' . esc_html( $labels[ $intent['workflow'] ] ) . ''; self::form( 'clear', $intent['workflow'] ); echo ''; submit_button( __( 'Forget this saved task', 'shieldlume-security' ), 'secondary', 'submit', false ); echo '
'; } /** A user-requested explanation is always reachable, regardless of suggestion preferences. */ public static function renderDetails(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only workflow selection is allowlisted; dispatch verifies the nonce and actor before any saved change. $flow = self::workflow( self::requestKey( $_GET, 'workflow' ) ); echo '

' . esc_html__( 'How the workflows work', 'shieldlume-security' ) . '

' . esc_html__( 'Free keeps complete evidence, native editing links and rescanning. Pro builds on the same detection engine. Read the details below; browsing does not save a task or change your settings.', 'shieldlume-security' ) . '

'; $copy = array( 'scheduling' => array( 'Flexible scan schedules', 'Choose 15-minute, hourly, six-hour or daily scan intervals, and optionally request changed-content checks after supported updates, plugin activations and theme changes. Compare recorded progress with the configured routine. Free includes manual and daily scans. Timing depends on WordPress cron; more frequent checks use more server resources and do not guarantee prevention.', 'Open scan schedules', 'Keep daily scanning', 'settings' ), 'investigations' => array( 'Grouped investigations', 'When several findings need review, Pro organizes observations by shared component or matching observation hash within a recorded day. Keep review guidance and an incident report with the investigation. These groups suggest where to look; they do not establish a common cause. All findings and their evidence remain available in Free.', 'Open investigations', 'Continue reviewing findings', 'findings' ), 'batches' => array( 'Reviewed repair batches', 'Review up to five current repair previews as one sequence, with your approval before each step and recorded verification afterward. Protected recovery and repair eligibility still apply; expired previews need fresh review. Pro supplies both eligible individual file restorations and reviewed batches.', 'Open reviewed repairs', 'Review findings', 'findings' ), ); foreach ( $copy as $key => $item ) { echo '' . esc_html( $item[0] ) . '

' . esc_html( $item[1] ) . '

'; echo '

' . esc_html( self::proInstalled() ? $item[2] : __( 'Pro access & installation', 'shieldlume-security' ) ) . '

' . esc_html( $item[3] ) . '

'; } echo '
' . esc_html__( 'Reports, daily summaries and routine history', 'shieldlume-security' ) . '

' . esc_html__( 'Bring scan coverage, findings and recorded repair outcomes into a printable incident report, then use your browser to print or save a PDF. Reports flag limits when the retained evidence exceeds their bounded view. Nothing is emailed or uploaded automatically.', 'shieldlume-security' ) . '

' . esc_html__( 'Optional daily summaries keep a local snapshot of findings, recorded repairs and outstanding work. Recording starts after you opt in; older days are not backfilled. Pro defaults routine history to 90 days and supports custom retention. Free keeps its evidence exports and findings. Shared Pro recovery records remain protected if Pro is deactivated.', 'shieldlume-security' ) . '

'; self::renderContinuation(); } }