usermeta, '/' ); $query = static function ( $sql ) use ( $table, $verify ) { if ( is_string( $sql ) && preg_match( '/^\s*(?:UPDATE\s+|INSERT\s+INTO\s+|DELETE\s+FROM\s+)`?' . $table . '`?\s/i', $sql ) ) { $verify(); } return $sql; }; foreach ( array( 'add_user_metadata', 'update_user_metadata', 'delete_user_metadata' ) as $hook ) { add_filter( $hook, $metadata, PHP_INT_MAX, 3 ); } add_filter( 'query', $query, PHP_INT_MAX ); try { $verify(); $result = $operation(); $verify(); return $result; } finally { foreach ( array( 'add_user_metadata', 'update_user_metadata', 'delete_user_metadata' ) as $hook ) { remove_filter( $hook, $metadata, PHP_INT_MAX ); } remove_filter( 'query', $query, PHP_INT_MAX ); } } public static function read( int $actor ): mixed { global $wpdb; wp_cache_delete( $actor, 'user_meta' ); $values = get_user_meta( $actor, self::key(), false ); if ( ! empty( $wpdb->last_error ) || ! is_array( $values ) || count( $values ) > 1 || ( 1 === count( $values ) && '' === $values[0] ) ) { throw new \RuntimeException( 'Saved views could not be read unambiguously. Review database access and retry.' ); } return $values ? $values[0] : ''; } public static function valid( mixed $raw, int $actor ): array { if ( '' === $raw ) { return array(); } if ( ! is_array( $raw ) || array_diff( array_keys( $raw ), array( 'schema', 'site', 'actor', 'items', 'generation' ) ) || 1 !== ( $raw['schema'] ?? null ) || $actor !== ( $raw['actor'] ?? null ) || self::site() !== ( $raw['site'] ?? null ) || ! is_array( $raw['items'] ?? null ) || count( $raw['items'] ) > 10 || ( isset( $raw['generation'] ) && ( ! is_string( $raw['generation'] ) || ! preg_match( '/^[a-f0-9]{32}$/D', $raw['generation'] ) ) ) ) { throw new \RuntimeException( 'Saved views have an invalid account or site binding. Review the local record before continuing.' ); } foreach ( $raw['items'] as $id => $item ) { if ( ! is_string( $id ) || ! preg_match( '/^[a-f0-9]{32}$/D', $id ) || ! is_array( $item ) || array_diff( array_keys( $item ), array( 'label', 'filters', 'saved_at' ) ) || ! is_string( $item['label'] ?? null ) || '' === trim( $item['label'] ) || strlen( $item['label'] ) > 120 || sanitize_text_field( $item['label'] ) !== $item['label'] || ! is_array( $item['filters'] ?? null ) || ! is_string( $item['saved_at'] ?? null ) || ! preg_match( '/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\+00:00$/D', $item['saved_at'] ) || FindingExport::filters( $item['filters'] ) !== $item['filters'] ) { throw new \RuntimeException( 'A saved view is malformed. Review the retained local record before continuing.' ); } } return $raw; } public static function register(): void { add_filter( 'wp_privacy_personal_data_exporters', static function ( $exporters ) { $exporters['shieldlume-saved-views'] = array( 'exporter_friendly_name' => __( 'Shieldlume saved finding views', 'shieldlume-security' ), 'callback' => array( self::class, 'exportRequest' ) ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['shieldlume-saved-views'] = array( 'eraser_friendly_name' => __( 'Shieldlume saved finding views', 'shieldlume-security' ), 'callback' => array( self::class, 'erase' ) ); return $erasers; } ); } private static function identity( int $actor, string $email, string $site ): void { clean_user_cache( $actor ); $user = get_user_by( 'email', $email ); if ( ! $user || $actor !== (int) $user->ID || ! hash_equals( $site, self::site() ) ) { throw new \RuntimeException( 'The requested account or site changed. Retry the privacy request.' ); } } private static function authority( int $requester, int $blog ): void { if ( is_multisite() || $requester < 1 || $requester !== get_current_user_id() || $blog !== get_current_blog_id() || ! WorkflowAuthority::administrator() || $requester !== get_current_user_id() || $blog !== get_current_blog_id() ) { throw new \RuntimeException( 'Current single-site administrator access is required for saved-view privacy requests.' ); } } /** Core accepts WP_Error from exporters but does not catch callback exceptions. */ public static function exportRequest( string $email, int $page = 1 ): array|\WP_Error { try { return self::export( $email, $page ); } catch ( \Throwable $error ) { return new \WP_Error( 'shieldlume_saved_views_export_unavailable', __( 'Saved finding views could not be exported safely. Review the current account, site and database state, then retry the privacy request.', 'shieldlume-security' ) ); } } /** WordPress core authorizes the privacy request for this email address. */ public static function export( string $email, int $page = 1 ): array { $requester = get_current_user_id(); $blog = get_current_blog_id(); self::authority( $requester, $blog ); $data = array(); $user = 1 === $page ? get_user_by( 'email', $email ) : false; if ( $user ) { $actor = (int) $user->ID; $site = self::site(); $store = new Store(); $name = self::lockName( $actor ); $owner = $store->lock( $name, 120 ); if ( ! $owner ) { throw new \RuntimeException( 'Saved views are being changed. Retry privacy export shortly.' ); } try { $raw = self::read( $actor ); $record = self::valid( $raw, $actor ); foreach ( $record['items'] ?? array() as $id => $item ) { $data[] = array( 'group_id' => 'shieldlume-saved-views', 'group_label' => __( 'Shieldlume saved finding views', 'shieldlume-security' ), 'item_id' => 'shieldlume-view-' . $id, 'data' => array( array( 'name' => __( 'View name', 'shieldlume-security' ), 'value' => $item['label'] ), array( 'name' => __( 'Applied filters', 'shieldlume-security' ), 'value' => wp_json_encode( $item['filters'] ) ), array( 'name' => __( 'Saved at (UTC)', 'shieldlume-security' ), 'value' => $item['saved_at'] ), ) ); } self::identity( $actor, $email, $site ); self::authority( $requester, $blog ); if ( ! $store->ownsLock( $name, $owner ) ) { throw new \RuntimeException( 'The saved-view export lock expired. Retry the privacy request.' ); } if ( self::read( $actor ) !== $raw ) { throw new \RuntimeException( 'Saved views changed during privacy export. Retry to export the current state.' ); } if ( $requester !== get_current_user_id() || $blog !== get_current_blog_id() ) { throw new \RuntimeException( 'The administrator or site changed before the saved-view export completed.' ); } } finally { $store->unlock( $name, $owner ); } } return array( 'data' => $data, 'done' => true ); } public static function erase( string $email, int $page = 1 ): array { $requester = get_current_user_id(); $blog = get_current_blog_id(); $removed = false; $retained = false; $user = 1 === $page ? get_user_by( 'email', $email ) : false; if ( $user ) { $actor = (int) $user->ID; $site = self::site(); $store = new Store(); $name = self::lockName( $actor ); $owner = null; try { self::authority( $requester, $blog ); $owner = $store->lock( $name, 120 ); if ( ! $owner ) { throw new \RuntimeException( 'Saved views are being changed. Retry privacy erasure shortly.' ); } $guard = static function () use ( $actor, $email, $site, $store, $name, $owner, $requester, $blog ): void { DataRemoval::assertWritable(); self::identity( $actor, $email, $site ); if ( ! $store->ownsLock( $name, $owner ) ) { throw new \RuntimeException( 'The saved-view erasure lock expired.' ); } self::authority( $requester, $blog ); }; $guard(); $raw = self::read( $actor ); self::valid( $raw, $actor ); if ( '' !== $raw && ! empty( $raw['items'] ) ) { // Keep only a fresh empty generation so an older form cannot recreate erased names/searches. $empty = array( 'schema' => 1, 'site' => $site, 'actor' => $actor, 'items' => array(), 'generation' => bin2hex( random_bytes( 16 ) ) ); $deleted = self::mutate( $actor, $guard, static fn() => update_user_meta( $actor, self::key(), $empty, $raw ) ); $removed = $deleted && $empty === self::read( $actor ); $retained = ! $removed; } } catch ( \Throwable $error ) { $retained = true; } finally { if ( $owner ) { $store->unlock( $name, $owner ); } } } $messages = $retained ? array( __( 'Some saved finding views could not be erased safely. Review the current account, site and database state, then retry.', 'shieldlume-security' ) ) : array(); if ( $removed ) { $messages[] = __( 'Saved view names and filters were erased. An empty local version marker remains to reject older forms; account deletion or reviewed local removal deletes that marker.', 'shieldlume-security' ); } return array( 'items_removed' => $removed, 'items_retained' => $retained, 'messages' => $messages, 'done' => true ); } }