'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 '
'; } public static function render( Store $store ): void { try { $state = self::offer( $store ); } catch ( \Throwable $error ) { return; } if ( ! $state ) { return; } echo '' . 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' ) . '
' . 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 '