'Shieldlume review invitation choices', 'callback' => array( self::class, 'privacyExport' ) ); return $items; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $items ) { $items['shieldlume-review'] = array( 'eraser_friendly_name' => 'Shieldlume review invitation choices', 'callback' => array( self::class, 'privacyErase' ) ); return $items; } ); } private static function site(): string { return hash( 'sha256', get_current_blog_id() . '|' . home_url( '/' ) ); } private static function key(): string { return '_antivirus_ai_review_' . get_current_blog_id(); } private static function authorized(): bool { return ! is_multisite() && get_current_user_id() > 0 && current_user_can( 'manage_options' ); } private static function method(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Type and length guard only; acceptance uses the sanitized exact value below. if ( ! isset( $_SERVER['REQUEST_METHOD'] ) || ! is_string( $_SERVER['REQUEST_METHOD'] ) || strlen( $_SERVER['REQUEST_METHOD'] ) > 16 ) { return ''; } $value = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Reject transformed input; the unsanitized comparison value is never retained or rendered. return $value === wp_unslash( $_SERVER['REQUEST_METHOD'] ) ? $value : ''; } private static function viewKey( array $input, string $key, string $default = '' ): string { $value = $input[ $key ] ?? $default; if ( ! is_string( $value ) || strlen( $value ) > 64 ) { return ''; } $value = wp_unslash( $value ); $clean = sanitize_key( $value ); return $value === $clean ? $clean : ''; } /** No discovery request: ordinary core metadata or an explicit publisher-reviewed setting only. */ public static function destination(): string { if ( defined( 'SHIELDLUME_REVIEW_URL_VERIFIED' ) ) { return true === SHIELDLUME_REVIEW_URL_VERIFIED && defined( 'SHIELDLUME_REVIEW_URL' ) && self::URL === SHIELDLUME_REVIEW_URL ? self::URL : ''; } $updates = get_site_transient( 'update_plugins' ); $now = time(); if ( ! is_object( $updates ) || ! is_int( $updates->last_checked ?? null ) || $updates->last_checked < $now - 172800 || $updates->last_checked > $now || ! is_array( $updates->checked ?? null ) || ( $updates->checked[ ANTIVIRUS_AI_BASENAME ] ?? null ) !== ANTIVIRUS_AI_VERSION ) { return ''; } foreach ( array( 'response', 'no_update' ) as $section ) { $entries = $updates->$section ?? null; $entry = is_array( $entries ) ? ( $entries[ ANTIVIRUS_AI_BASENAME ] ?? null ) : null; if ( is_object( $entry ) && 'shieldlume-security' === ( $entry->slug ?? null ) && ANTIVIRUS_AI_BASENAME === ( $entry->plugin ?? null ) && 'w.org/plugins/shieldlume-security' === ( $entry->id ?? null ) && 'https://wordpress.org/plugins/shieldlume-security/' === ( $entry->url ?? null ) ) { return self::URL; } } return ''; } private static function usage( ?array $record ): ?array { if ( ! $record || ( $record['site'] ?? null ) !== self::site() ) { return null; } if ( 'review_usage' !== ( $record['_kind'] ?? '' ) || ! is_int( $record['first'] ?? null ) || ! is_int( $record['last'] ?? null ) || $record['first'] <= 0 || $record['last'] < $record['first'] || $record['last'] > time() || ! is_array( $record['days'] ?? null ) || count( $record['days'] ) < 1 || count( $record['days'] ) > 3 ) { throw new \UnexpectedValueException( 'Invalid local review usage.' ); } $days = $record['days']; foreach ( $days as $day ) { if ( ! is_string( $day ) || ! preg_match( '/^\d{4}-\d{2}-\d{2}$/D', $day ) || gmdate( 'Y-m-d', strtotime( $day . ' UTC' ) ?: 0 ) !== $day ) { throw new \UnexpectedValueException( 'Invalid local review day.' ); } } $sorted = array_values( array_unique( $days ) ); sort( $sorted ); if ( $days !== $sorted || $days[0] !== gmdate( 'Y-m-d', $record['first'] ) || end( $days ) !== gmdate( 'Y-m-d', $record['last'] ) ) { throw new \UnexpectedValueException( 'Invalid local review chronology.' ); } return $record; } /** Count finished broad jobs, including limited coverage, with actual checks; never inspect findings, severity or result_state. */ public static function completed( string $id ): void { if ( is_multisite() || ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/D', $id ) ) { return; } $store = new Store(); $owner = null; try { $scan = $store->get( $id ); $now = time(); if ( ! $scan || 'scan' !== $scan['_kind'] || ! in_array( $scan['_status'], array( 'completed', 'incomplete' ), true ) || $scan['_status'] !== ( $scan['status'] ?? '' ) || 'finished' !== ( $scan['phase'] ?? '' ) || ! empty( $scan['target_finding_id'] ) || ( (int) ( $scan['counters']['checked'] ?? 0 ) + (int) ( $scan['counters']['database_checked'] ?? 0 ) ) < 1 || ! is_string( $scan['completed_at'] ?? null ) ) { return; } $at = strtotime( $scan['completed_at'] ); // Only the completion event counts; retained historical records are not backfilled. if ( false === $at || gmdate( 'c', $at ) !== $scan['completed_at'] || $at > $now || $at < $now - 300 ) { return; } $owner = $store->lock( 'review_usage', 30 ); if ( ! $owner ) { return; } $old = $store->get( self::USAGE ); $usage = self::usage( $old ); $day = gmdate( 'Y-m-d', $at ); if ( $usage && ( count( $usage['days'] ) >= 3 || in_array( $day, $usage['days'], true ) || $at < $usage['last'] ) ) { return; } $next = $usage ?: array( 'site' => self::site(), 'first' => $at, 'days' => array() ); $next['last'] = $at; $next['days'][] = $day; $store->put( 'review_usage', self::USAGE, $next, 'active', $old['_revision'] ?? null ); } catch ( \Throwable $error ) { /* An optional invitation must never interrupt security work. */ } finally { if ( $owner ) { $store->unlock( 'review_usage', $owner ); } } } private static function state( int $actor ): array { wp_cache_delete( $actor, 'user_meta' ); $value = get_user_meta( $actor, self::key(), true ); global $wpdb; if ( ! empty( $wpdb->last_error ) ) { throw new \RuntimeException( 'Review choices could not be read.' ); } if ( '' === $value || ( is_array( $value ) && ( $value['site'] ?? null ) !== self::site() ) ) { return array( 'site' => self::site(), 'shown' => 0, 'last' => 0, 'next' => 0, 'closed' => false, 'token' => '' ); } if ( ! is_array( $value ) || ! is_int( $value['shown'] ?? null ) || $value['shown'] < 0 || $value['shown'] > 3 || ! is_int( $value['last'] ?? null ) || ! is_int( $value['next'] ?? null ) || $value['last'] < 0 || $value['next'] < 0 || ! is_bool( $value['closed'] ?? null ) || ! is_string( $value['token'] ?? null ) || ( '' !== $value['token'] && ! preg_match( '/^[a-f0-9]{32}$/D', $value['token'] ) ) ) { throw new \UnexpectedValueException( 'Invalid local review choice.' ); } return $value; } private static function save( int $actor, array $state ): void { DataRemoval::assertWritable(); if ( ! update_user_meta( $actor, self::key(), $state ) && self::state( $actor ) !== $state ) { throw new \RuntimeException( 'The review choice could not be saved.' ); } } private static function locked( Store $store, callable $action ): mixed { if ( ! self::authorized() ) { throw new \RuntimeException( 'Administrator access is required.' ); } $actor = get_current_user_id(); $site = self::site(); $name = 'review_' . substr( hash( 'sha256', $site . '|' . $actor ), 0, 48 ); $owner = $store->lock( $name, 30 ); if ( ! $owner ) { throw new \RuntimeException( 'Review choices are busy; reload and retry.' ); } try { if ( ! self::authorized() || get_current_user_id() !== $actor || self::site() !== $site ) { throw new \RuntimeException( 'Administrator context changed.' ); } return $action( $actor, self::state( $actor ) ); } finally { $store->unlock( $name, $owner ); } } /** One display per 30 days, at most three per administrator/site, including ignored invitations. */ public static function offer( Store $store ): ?array { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Exact local view context only; this records a bounded impression, never consent or a security setting. if ( ! self::authorized() || ! is_admin() || 'GET' !== self::method() || 'antivirus-ai' !== self::viewKey( $_GET, 'page' ) || 'overview' !== self::viewKey( $_GET, 'tab', 'overview' ) || '' === self::destination() ) { return null; } return self::locked( $store, static function ( int $actor, array $state ) use ( $store ): ?array { $now = time(); $usage = self::usage( $store->get( self::USAGE ) ); if ( ! $usage || count( $usage['days'] ) < 3 || $now < $usage['first'] + self::INTERVAL || $now <= $usage['last'] || $state['closed'] || $state['shown'] >= 3 || $now < $state['next'] ) { return null; } ++$state['shown']; $state['last'] = $now; $state['next'] = $now + self::INTERVAL; $state['token'] = bin2hex( random_bytes( 16 ) ); self::save( $actor, $state ); return $state; } ); } private static function form( string $operation, string $label, string $token = '', string $context = 'dashboard' ): void { echo '
'; wp_nonce_field( 'antivirus_ai_review_' . $operation, '_avai_review_nonce' ); echo '
'; } public static function render( Store $store ): void { try { $state = self::offer( $store ); } catch ( \Throwable $error ) { return; } if ( ! $state ) { return; } echo '

' . esc_html__( 'Share your experience', 'shieldlume-security' ) . '

' . esc_html__( 'You have used Shieldlume Security across several days for at least a month. An honest review can help other site owners decide whether it suits them. All feedback is welcome.', 'shieldlume-security' ) . '

'; self::form( 'review', __( 'Leave an honest review', 'shieldlume-security' ), $state['token'] ); self::form( 'remind', __( 'Remind me in 30 days', 'shieldlume-security' ), $state['token'] ); self::form( 'never', __( 'Never ask again', 'shieldlume-security' ), $state['token'] ); echo '

' . esc_html__( 'Your choice stays on this site. No scan details or site identifiers are sent with the review link.', 'shieldlume-security' ) . '

'; } public static function help(): void { if ( ! self::authorized() ) { return; } echo '

' . esc_html__( 'Share your experience', 'shieldlume-security' ) . '

'; if ( '' !== self::destination() ) { echo '

' . esc_html__( 'All honest feedback is welcome. Reviewing is optional and does not affect your security tools.', 'shieldlume-security' ) . '

'; self::form( 'review', __( 'Leave an honest review on WordPress.org', 'shieldlume-security' ), '', 'help' ); } else { echo '

' . esc_html__( 'The WordPress.org review link will be available here when the directory destination is confirmed.', 'shieldlume-security' ) . '

'; } echo '
'; } private static function input( array $input, string $key, string $pattern ): string { $value = $input[ $key ] ?? null; if ( ! is_string( $value ) || strlen( $value ) > 64 || ! preg_match( $pattern, $value ) ) { throw new \InvalidArgumentException( 'Invalid review request.' ); } return $value; } public static function dispatch( array $input, Store $store ): string { if ( ! self::authorized() || 'POST' !== self::method() ) { throw new \RuntimeException( 'Authorized POST required.' ); } $operation = self::input( $input, 'review_operation', '/^(review|remind|never)$/D' ); $context = self::input( $input, 'review_context', '/^(dashboard|help)$/D' ); $token = self::input( $input, 'review_token', '/^(?:[a-f0-9]{32})?$/D' ); $nonce = self::input( $input, '_avai_review_nonce', '/^[A-Za-z0-9]{1,64}$/D' ); if ( ! wp_verify_nonce( $nonce, 'antivirus_ai_review_' . $operation ) || ( 'help' === $context && ( 'review' !== $operation || '' !== $token ) ) ) { throw new \InvalidArgumentException( 'Invalid review request.' ); } return self::locked( $store, static function ( int $actor, array $state ) use ( $operation, $context, $token ): string { if ( 'dashboard' === $context && ( '' === $token || ! hash_equals( $state['token'], $token ) || $state['closed'] || time() > $state['last'] + self::INTERVAL ) ) { throw new \RuntimeException( 'This invitation has expired; use Help & diagnostics.' ); } $destination = self::destination(); if ( 'review' === $operation && '' === $destination ) { throw new \RuntimeException( 'The directory review destination is not confirmed.' ); } if ( 'remind' === $operation ) { $state['next'] = time() + self::INTERVAL; } else { $state['closed'] = true; } $state['token'] = ''; self::save( $actor, $state ); return 'review' === $operation ? $destination : Admin::url( 'overview' ); } ); } public static function handle(): void { try { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- dispatch validates the operation-bound nonce, capability, method and local context before writing. $destination = self::dispatch( wp_unslash( $_POST ), new Store() ); } catch ( \Throwable $error ) { wp_die( esc_html( $error->getMessage() ), '', array( 'response' => 400 ) ); } nocache_headers(); header( 'Referrer-Policy: no-referrer' ); if ( self::URL === $destination ) { // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- Exact server-owned HTTPS WordPress.org review URL; no request-derived destination or query identifiers. wp_redirect( $destination, 303 ); } else { wp_safe_redirect( $destination, 303 ); } exit; } public static function privacyExport( string $email, int $page = 1 ): array { $user = 1 === $page ? get_user_by( 'email', $email ) : false; $data = array(); if ( $user ) { $value = get_user_meta( $user->ID, self::key(), true ); if ( is_array( $value ) ) { $fields = array(); foreach ( array( 'shown' => 'Invitations displayed', 'last' => 'Last display (Unix time)', 'next' => 'Next eligible display (Unix time)', 'closed' => 'Further invitations stopped' ) as $key => $label ) { $fields[] = array( 'name' => $label, 'value' => 'closed' === $key ? ( empty( $value[ $key ] ) ? 'No' : 'Yes' ) : (string) (int) ( $value[ $key ] ?? 0 ) ); } $data[] = array( 'group_id' => 'shieldlume-review', 'group_label' => 'Shieldlume review invitation choices', 'item_id' => 'shieldlume-review-' . get_current_blog_id(), '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 && '' !== get_user_meta( $user->ID, self::key(), true ) ) { $store = new Store(); $name = 'review_' . substr( hash( 'sha256', self::site() . '|' . $user->ID ), 0, 48 ); $owner = null; try { $owner = $store->lock( $name, 30 ); if ( ! $owner ) { throw new \RuntimeException( 'Review choices are busy.' ); } DataRemoval::assertWritable(); delete_user_meta( $user->ID, self::key() ); wp_cache_delete( $user->ID, 'user_meta' ); if ( '' !== get_user_meta( $user->ID, self::key(), true ) ) { throw new \RuntimeException( 'Review choices could not be erased.' ); } $removed = true; } catch ( \Throwable $error ) { $retained = true; } finally { if ( $owner ) { $store->unlock( $name, $owner ); } } } return array( 'items_removed' => $removed, 'items_retained' => $retained, 'messages' => $retained ? array( __( 'Review invitation choices could not be erased; restore database access and retry.', 'shieldlume-security' ) ) : ( $removed ? array( __( 'Local review invitation choices were erased, including any dismissal. A future eligible dashboard visit may offer a review again. No WordPress.org review was changed.', 'shieldlume-security' ) ) : array() ), 'done' => true ); } }