*/ public static function all(): array { $sources = []; foreach ( [ self::core_privacy(), self::flamingo(), self::jetformbuilder(), self::wpforms() ] as $source ) { if ( $source ) { $sources[ $source['id'] ] = $source; } } /** * Register an additional place where this site keeps personal data. * * Each entry needs an id, a label and a `find` callable; `erase` is * optional, and a source without it is reported as pending instead of * being silently counted as erased. * * @param array $sources Sources keyed by id. */ $sources = apply_filters( 'ago_legal_data_sources', $sources ); return array_filter( $sources, static function ( $source ) { return is_array( $source ) && ! empty( $source['id'] ) && isset( $source['find'] ) && is_callable( $source['find'] ); } ); } /** * Sources present on this site, without querying any personal data. * Used by the admin to show which stores are covered. * * @return array */ public static function detected(): array { $out = []; foreach ( self::all() as $source ) { $out[] = [ 'id' => $source['id'], 'label' => $source['label'], 'can_erase' => ! empty( $source['erase'] ) && is_callable( $source['erase'] ), ]; } return $out; } /** * Everything the registered sources hold about an email address. * * @return array{sections: array, origins: array} */ public static function find( string $email ): array { $sections = []; $origins = []; foreach ( self::all() as $source ) { try { $rows = (array) call_user_func( $source['find'], $email ); } catch ( \Throwable $e ) { continue; } if ( $rows ) { $sections[ $source['id'] ] = $rows; $origins[ $source['id'] ] = $source['label']; } } return [ 'sections' => $sections, 'origins' => $origins ]; } /** * Erase what can be erased and report honestly what could not. * * @return array{erased: array, pending: array} */ public static function erase( string $email ): array { $erased = []; $pending = []; foreach ( self::all() as $source ) { $has_data = false; try { $has_data = (bool) call_user_func( $source['find'], $email ); } catch ( \Throwable $e ) { $has_data = true; // cannot tell: report it rather than assume it is clean } if ( empty( $source['erase'] ) || ! is_callable( $source['erase'] ) ) { if ( $has_data ) { $pending[] = [ 'source' => $source['label'], 'reason' => $source['erase_note'] ?? __( 'This source does not support automatic deletion. Delete these records manually.', 'ago-legal' ), ]; } continue; } try { $erased[ $source['id'] ] = (int) call_user_func( $source['erase'], $email ); } catch ( \Throwable $e ) { $pending[] = [ 'source' => $source['label'], 'reason' => __( 'Deletion failed. Check this source manually.', 'ago-legal' ), ]; } } return [ 'erased' => $erased, 'pending' => $pending ]; } /* ───── WordPress privacy registry ───── */ /** * Bridge to `wp_privacy_personal_data_exporters` / `_erasers`, which is where * the rest of the ecosystem already declares its personal data: core media, * WooCommerce orders and customers, MailPoet subscribers, EDD, and any plugin * that implements the standard. */ private static function core_privacy(): ?array { return [ 'id' => 'wordpress_privacy', 'label' => __( 'Plugins registered in the WordPress privacy tools', 'ago-legal' ), 'find' => static function ( string $email ): array { $exporters = apply_filters( 'wp_privacy_personal_data_exporters', [] ); $rows = []; foreach ( $exporters as $key => $exporter ) { if ( in_array( $key, self::CORE_SKIP, true ) || empty( $exporter['callback'] ) || ! is_callable( $exporter['callback'] ) ) { continue; } $page = 1; do { $response = call_user_func( $exporter['callback'], $email, $page ); if ( ! is_array( $response ) ) { break; } foreach ( (array) ( $response['data'] ?? [] ) as $item ) { $fields = []; foreach ( (array) ( $item['data'] ?? [] ) as $field ) { if ( isset( $field['name'] ) ) { $fields[ (string) $field['name'] ] = $field['value'] ?? ''; } } $rows[] = [ 'provided_by' => $exporter['exporter_friendly_name'] ?? $key, 'group' => $item['group_label'] ?? ( $item['group_id'] ?? '' ), 'data' => $fields, ]; } $page++; } while ( empty( $response['done'] ) && $page <= 20 ); } return $rows; }, 'erase' => static function ( string $email ): int { $erasers = apply_filters( 'wp_privacy_personal_data_erasers', [] ); $removed = 0; foreach ( $erasers as $key => $eraser ) { if ( in_array( $key, self::CORE_SKIP, true ) || empty( $eraser['callback'] ) || ! is_callable( $eraser['callback'] ) ) { continue; } $page = 1; do { $response = call_user_func( $eraser['callback'], $email, $page ); if ( ! is_array( $response ) ) { break; } if ( ! empty( $response['items_removed'] ) ) { $removed++; } $page++; } while ( empty( $response['done'] ) && $page <= 20 ); } return $removed; }, ]; } /* ───── Contact Form 7, stored by Flamingo ───── */ private static function flamingo(): ?array { if ( ! post_type_exists( 'flamingo_inbound' ) ) { return null; } $lookup = static function ( string $email ): array { $ids = get_posts( [ 'post_type' => [ 'flamingo_inbound', 'flamingo_contact' ], 'post_status' => 'any', 'posts_per_page' => 500, 'fields' => 'ids', 'suppress_filters' => false, 'meta_query' => [ 'relation' => 'OR', [ 'key' => '_from_email', 'value' => $email ], [ 'key' => '_email', 'value' => $email ], ], ] ); return array_map( 'intval', (array) $ids ); }; return [ 'id' => 'contact_form_7', 'label' => __( 'Contact Form 7 submissions (Flamingo)', 'ago-legal' ), 'find' => static function ( string $email ) use ( $lookup ): array { $rows = []; foreach ( $lookup( $email ) as $id ) { $fields = get_post_meta( $id, '_fields', true ); $rows[] = [ 'id' => $id, 'form' => get_the_title( $id ), 'date' => get_post_field( 'post_date', $id ), 'fields' => is_array( $fields ) ? $fields : [], ]; } return $rows; }, 'erase' => static function ( string $email ) use ( $lookup ): int { $count = 0; foreach ( $lookup( $email ) as $id ) { if ( wp_delete_post( $id, true ) ) { $count++; } } return $count; }, ]; } /* ───── JetFormBuilder (Crocoblock) ───── */ private static function jetformbuilder(): ?array { global $wpdb; $records = $wpdb->prefix . 'jet_fb_records'; $fields = $wpdb->prefix . 'jet_fb_records_fields'; if ( ! self::table_exists( $records ) || ! self::table_exists( $fields ) ) { return null; } $lookup = static function ( string $email ) use ( $wpdb, $fields ): array { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT record_id FROM {$fields} WHERE field_value = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $email ) ); return array_map( 'intval', (array) $ids ); }; return [ 'id' => 'jetformbuilder', 'label' => __( 'JetFormBuilder submissions', 'ago-legal' ), 'find' => static function ( string $email ) use ( $wpdb, $records, $fields, $lookup ): array { $ids = $lookup( $email ); if ( ! $ids ) { return []; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, form_id, status, created_at FROM {$records} WHERE id IN ({$in})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ...$ids ) ); $out = []; foreach ( (array) $rows as $row ) { $values = $wpdb->get_results( $wpdb->prepare( "SELECT field_name, field_value FROM {$fields} WHERE record_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row->id ) ); $data = []; foreach ( (array) $values as $value ) { $data[ $value->field_name ] = $value->field_value; } $out[] = [ 'id' => (int) $row->id, 'form' => get_the_title( (int) $row->form_id ) ?: (int) $row->form_id, 'date' => $row->created_at ?? '', 'fields' => $data, ]; } return $out; }, 'erase' => static function ( string $email ) use ( $wpdb, $records, $fields, $lookup ): int { $ids = $lookup( $email ); if ( ! $ids ) { return 0; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM {$fields} WHERE record_id IN ({$in})", ...$ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$records} WHERE id IN ({$in})", ...$ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared return count( $ids ); }, ]; } /* ───── WPForms (entries exist in the paid tier) ───── */ private static function wpforms(): ?array { global $wpdb; $entries = $wpdb->prefix . 'wpforms_entries'; $values = $wpdb->prefix . 'wpforms_entry_fields'; if ( ! self::table_exists( $entries ) ) { return null; } $has_values = self::table_exists( $values ); $lookup = static function ( string $email ) use ( $wpdb, $entries, $values, $has_values ): array { if ( $has_values ) { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT entry_id FROM {$values} WHERE value = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $email ) ); } else { $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$entries} WHERE fields LIKE %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared '%' . $wpdb->esc_like( $email ) . '%' ) ); } return array_map( 'intval', (array) $ids ); }; return [ 'id' => 'wpforms', 'label' => __( 'WPForms entries', 'ago-legal' ), 'find' => static function ( string $email ) use ( $wpdb, $entries, $lookup ): array { $ids = $lookup( $email ); if ( ! $ids ) { return []; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT entry_id, form_id, fields, date FROM {$entries} WHERE entry_id IN ({$in})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ...$ids ) ); $out = []; foreach ( (array) $rows as $row ) { $decoded = json_decode( (string) $row->fields, true ); $out[] = [ 'id' => (int) $row->entry_id, 'form' => get_the_title( (int) $row->form_id ) ?: (int) $row->form_id, 'date' => $row->date ?? '', 'fields' => is_array( $decoded ) ? $decoded : (string) $row->fields, ]; } return $out; }, 'erase' => static function ( string $email ) use ( $wpdb, $entries, $values, $has_values, $lookup ): int { $ids = $lookup( $email ); if ( ! $ids ) { return 0; } $in = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); if ( $has_values ) { $wpdb->query( $wpdb->prepare( "DELETE FROM {$values} WHERE entry_id IN ({$in})", ...$ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared } $wpdb->query( $wpdb->prepare( "DELETE FROM {$entries} WHERE entry_id IN ({$in})", ...$ids ) ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared return count( $ids ); }, ]; } /* ───── helpers ───── */ private static function table_exists( string $table ): bool { global $wpdb; static $cache = []; if ( ! isset( $cache[ $table ] ) ) { $cache[ $table ] = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; } return $cache[ $table ]; } }