last_error ) ) { throw new \RuntimeException( 'Removal state could not be read safely. Restore database access and retry.' ); } return $value; } private static function save( string $name, array $value ): void { self::$bookkeeping = true; try { update_option( $name, $value, false ); } finally { self::$bookkeeping = false; } if ( self::read( $name, null ) !== $value ) { throw new \RuntimeException( 'Removal authorization could not be saved. No deletion is authorized.' ); } } private static function forget( string $name ): void { delete_option( $name ); if ( null !== self::read( $name, null ) ) { throw new \RuntimeException( 'Removal authorization could not be cleared. Restore database access and retry.' ); } } public static function binding(): string { return hash( 'sha256', home_url( '/' ) . '|' . trailingslashit( InstallationPaths::wordpress() ) . '|' . get_current_blog_id() ); } /** Called by Store immediately before a mutation or a new work lease, never for reads/unlock. */ public static function assertWritable(): void { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- One uncached indexed lookup provides the removal fence without repeatedly invalidating the site's entire options cache on every scan write. $present = $wpdb->get_var( $wpdb->prepare( 'SELECT option_name FROM %i WHERE option_name=%s LIMIT 1', $wpdb->options, self::FENCE ) ); if ( ! empty( $wpdb->last_error ) ) { throw new \RuntimeException( 'The data-removal fence could not be checked. Restore database access before starting work.' ); } if ( null !== $present ) { throw new \RuntimeException( 'Shieldlume Security data removal is in progress or needs review. Reactivate and review Data & uninstall before starting more work.' ); } } /** Reactivation is an explicit cancellation; it never restores already removed records. */ public static function activated(): void { $state = self::read( self::INTENT, array() ); $fence = self::read( self::FENCE, false ); $completed = array( 'schema' => 1, 'phase' => 'complete' ) === $fence; if ( ! $completed && ( false !== $fence || ( is_array( $state ) && ! empty( $state['started_at'] ) ) ) ) { self::save( self::WARNING, array( 'at' => time(), 'message' => 'An incomplete removal was cancelled by reactivation. Previously removed data is not restored. Review the remaining data before authorizing another removal.' ) ); } self::forget( self::FENCE ); self::forget( self::INTENT ); } /** Record successful shutdown; deletion still checks work, leases and inventory again. */ public static function deactivated(): void { $state = self::read( self::INTENT, array() ); if ( is_array( $state ) && 'armed' === ( $state['phase'] ?? '' ) && ( $state['binding'] ?? '' ) === self::binding() ) { $state['inactive_since'] = time(); self::save( self::INTENT, $state ); } } public static function fence( array $state ): void { self::save( self::FENCE, array( 'schema' => 1, 'binding' => self::binding(), 'actor' => $state['actor'], 'started_at' => $state['started_at'] ?? time() ) ); $state['phase'] = 'executing'; $state['started_at'] = $state['started_at'] ?? time(); self::save( self::INTENT, $state ); } public static function completed(): void { self::save( self::FENCE, array( 'schema' => 1, 'phase' => 'complete' ) ); self::forget( self::INTENT ); } private static function scalar( array $values, string $key ): string { if ( ! isset( $values[ $key ] ) || ! is_string( $values[ $key ] ) || strlen( $values[ $key ] ) > 160 ) { return ''; } $value = sanitize_text_field( wp_unslash( $values[ $key ] ) ); return strlen( $value ) <= 80 && $value === wp_unslash( $values[ $key ] ) ? $value : ''; } private static function authority(): void { if ( is_multisite() || get_current_user_id() < 1 || ! current_user_can( 'manage_options' ) || ! current_user_can( 'delete_plugins' ) ) { throw new \RuntimeException( 'A single-site administrator with permission to delete plugins is required.' ); } } public static function handle(): void { try { self::authority(); } catch ( \Throwable $error ) { wp_die( esc_html( $error->getMessage() ), esc_html__( 'Not authorized', 'shieldlume-security' ), array( 'response' => 403 ) ); } try { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) && strlen( $_SERVER['REQUEST_METHOD'] ) <= 16 ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( 'POST' !== $method || $method !== wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) { throw new \RuntimeException( 'A POST request is required.' ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read only the fresh nonce inputs here; verify the timestamp-bound nonce immediately below before dispatching an action. $issued = self::scalar( $_POST, 'removal_issued' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Only nonce type and size are inspected here; the bounded nonce is unslashed, sanitized and verified below before dispatch or mutation. if ( ! isset( $_POST['_avai_removal_nonce'] ) || ! is_string( $_POST['_avai_removal_nonce'] ) || strlen( $_POST['_avai_removal_nonce'] ) > 20 ) { throw new \RuntimeException( 'Reload this removal form.' ); } $nonce = sanitize_text_field( wp_unslash( $_POST['_avai_removal_nonce'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only size or unchanged-byte validation uses the original input here; the accepted value is unslashed and sanitized, and normalization cannot create an allowed method, action or credential. if ( $nonce !== wp_unslash( $_POST['_avai_removal_nonce'] ) || ! preg_match( '/^[a-zA-Z0-9]{1,20}$/D', $nonce ) ) { throw new \RuntimeException( 'Reload this removal form.' ); } if ( ! preg_match( '/^[0-9]{1,10}$/D', $issued ) || (int) $issued > time() || (int) $issued < time() - 600 ) { throw new \RuntimeException( 'This removal form expired. Reload Data & uninstall and review it again.' ); } if ( ! wp_verify_nonce( $nonce, 'antivirus_ai_data_removal_' . $issued ) ) { throw new \RuntimeException( 'This removal form could not be verified. Reload Data & uninstall and review it again.' ); } $operation = self::scalar( $_POST, 'removal_operation' ); if ( 'cancel' === $operation ) { self::activated(); $message = __( 'Keep data is selected. Removal authorization is cancelled; already removed data cannot be restored by cancellation.', 'shieldlume-security' ); } elseif ( 'prepare' === $operation ) { if ( '1' !== self::scalar( $_POST, 'withdraw_welcome' ) ) { throw new \RuntimeException( 'Confirm that you want to stop welcome sharing and request removal of its reports.' ); } $result = AccountSecurity::management( static function () { return Welcome::prepareRemoval(); } ); if ( is_wp_error( $result ) ) { throw new \RuntimeException( $result->get_error_message() ); } $message = __( 'Welcome sharing is stopped and its withdrawal is accepted. This did not erase local security records or unsubscribe confirmed email. Review the remaining checks before arming removal.', 'shieldlume-security' ); } elseif ( 'arm' === $operation ) { if ( self::PHRASE !== self::scalar( $_POST, 'removal_phrase' ) || '1' !== self::scalar( $_POST, 'remove_recovery' ) || '1' !== self::scalar( $_POST, 'understand_external' ) ) { throw new \RuntimeException( 'Review both acknowledgments and type DELETE SHIELDLUME DATA exactly.' ); } AccountSecurity::management( static function () { self::assertWritable(); if ( ! Welcome::removalReady() ) { throw new \RuntimeException( 'Stop welcome sharing and finish its withdrawal before authorizing removal. A pending credential must remain available for retry.' ); } DataCleanup::inspect( false ); self::save( self::INTENT, array( 'schema' => 1, 'phase' => 'armed', 'actor' => get_current_user_id(), 'binding' => self::binding(), 'armed_at' => time(), 'expires' => time() + 1800, 'inactive_since' => 0 ) ); } ); $message = __( 'Removal is armed for 30 minutes for this administrator. Nothing was erased. Deactivate Free, wait at least two minutes, then use WordPress Delete. You can cancel here or reactivate to keep the remaining data.', 'shieldlume-security' ); } else { throw new \RuntimeException( 'Choose a valid data-removal action.' ); } $kind = 'success'; } catch ( \Throwable $error ) { $message = $error->getMessage(); $kind = 'error'; } set_transient( 'antivirus_ai_notice_' . get_current_user_id(), array( 'kind' => $kind, 'message' => $message ), 120 ); wp_safe_redirect( Admin::url( 'data' ) ); exit; } private static function form( string $operation ): void { $issued = (string) time(); echo '
'; } echo '' . esc_html__( 'Use this only when you want to give up Shieldlume Security settings, security records and recovery copies. Removal happens during WordPress Delete, after your separate approval.', 'shieldlume-security' ) . '
'; self::removalSteps(); self::scopeDetails(); if ( ! Welcome::removalReady() ) { echo '' . esc_html__( 'This connection must finish withdrawal before local removal can be approved. Viewing this page sends no request.', 'shieldlume-security' ) . '
'; self::form( 'prepare' ); echo ''; self::authentication(); echo ''; } else { try { $review = DataCleanup::inspect( false ); echo '' . esc_html( sprintf( __( 'Current review: %1$d settings/cache entries, %2$d plugin records and %3$d recovery files. Counts are checked again at deletion.', 'shieldlume-security' ), $review['option_count'], $review['record_count'], count( $review['files'] ) ) ) . '
'; self::form( 'arm' ); echo ''; self::authentication(); echo ''; } catch ( \Throwable $error ) { self::blocker( $error->getMessage() ); } } echo '