robotstxt-mediaaudit/includes/Privacy/DataExporter.php
2026-06-03 06:24:03 +00:00

316 lines
9.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* GDPR personal data exporter.
*
* @package MediaRightsAudit\Privacy
*/
namespace MediaRightsAudit\Privacy;
/**
* Exports Media Audit data for a given user via the WordPress privacy tools.
*
* Registered via the wp_privacy_personal_data_exporters filter. Exports rows
* from mra_media_index and mra_external_results for all attachments uploaded
* by the requested user. Results are paginated at 20 attachments per page.
*/
class DataExporter {
/**
* Group ID for media index export items.
*/
const GROUP_INDEX = 'mra-media-index';
/**
* Group ID for external results export items.
*/
const GROUP_RESULTS = 'mra-external-results';
/**
* Number of attachments processed per export page.
*/
const PER_PAGE = 20;
/**
* Registers this exporter with the WordPress privacy API.
*
* Hooked to wp_privacy_personal_data_exporters.
*
* @param array<string, mixed> $exporters Accumulated exporter list.
*
* @return array<string, mixed>
*/
public static function register( array $exporters ): array {
$exporters['robotstxt-mediaaudit'] = array(
'exporter_friendly_name' => __( 'Media Audit', 'robotstxt-mediaaudit' ),
'callback' => array( self::class, 'export' ),
);
return $exporters;
}
/**
* Exports Media Audit data for the user identified by $email_address.
*
* @param string $email_address User e-mail address.
* @param int $page Current pagination page (1-based).
*
* @return array{data: array<mixed>, done: bool}
*/
public static function export( string $email_address, int $page = 1 ): array {
$user = get_user_by( 'email', $email_address );
if ( ! $user instanceof \WP_User ) {
return array(
'data' => array(),
'done' => true,
);
}
$offset = ( $page - 1 ) * self::PER_PAGE;
$attachment_ids = self::get_attachment_ids( $user->ID, $offset, self::PER_PAGE );
if ( empty( $attachment_ids ) ) {
return array(
'data' => array(),
'done' => true,
);
}
$data = array();
foreach ( self::get_index_rows( $attachment_ids ) as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$aid_val = $row['attachment_id'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
$data[] = array(
'group_id' => self::GROUP_INDEX,
'group_label' => __( 'Media Audit Indexed Files', 'robotstxt-mediaaudit' ),
'item_id' => 'mra-index-' . $aid,
'data' => self::format_index_row( $row, $aid ),
);
}
foreach ( self::get_result_rows( $attachment_ids ) as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$aid_val = $row['attachment_id'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
$provider_val = $row['provider'] ?? null;
$provider = is_string( $provider_val ) ? $provider_val : '';
$data[] = array(
'group_id' => self::GROUP_RESULTS,
'group_label' => __( 'Media Audit External Scan Results', 'robotstxt-mediaaudit' ),
'item_id' => 'mra-result-' . $aid . '-' . $provider,
'data' => self::format_result_row( $row, $aid, $provider ),
);
}
$total = self::count_attachments( $user->ID );
$done = ( $page * self::PER_PAGE ) >= $total;
return array(
'data' => $data,
'done' => $done,
);
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Returns attachment IDs from the plugin index for a given user, paginated.
*
* @param int $user_id WordPress user ID.
* @param int $offset Number of rows to skip.
* @param int $limit Maximum number of rows to return.
*
* @return array<int, int>
*/
private static function get_attachment_ids( int $user_id, int $offset, int $limit ): array {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw = $wpdb->get_col(
$wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->prefix}mra_media_index mi ON p.ID = mi.attachment_id
WHERE p.post_type = 'attachment'
AND p.post_author = %d
ORDER BY p.ID ASC
LIMIT %d OFFSET %d",
$user_id,
$limit,
$offset
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! is_array( $raw ) ) {
return array();
}
return array_map( static fn ( mixed $v ): int => is_numeric( $v ) ? (int) $v : 0, $raw );
}
/**
* Counts all indexed attachments for a given user.
*
* @param int $user_id WordPress user ID.
*
* @return int
*/
private static function count_attachments( int $user_id ): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$result = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*)
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->prefix}mra_media_index mi ON p.ID = mi.attachment_id
WHERE p.post_type = 'attachment'
AND p.post_author = %d",
$user_id
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return is_numeric( $result ) ? (int) $result : 0;
}
/**
* Fetches index rows for the given attachment IDs.
*
* @param array<int, int> $ids Attachment IDs.
*
* @return array<mixed>
*/
private static function get_index_rows( array $ids ): array {
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, file_name, file_url, mime_type, external_status,
internal_scanned_at, external_scanned_at
FROM {$wpdb->prefix}mra_media_index
WHERE attachment_id IN ({$placeholders})",
...$ids
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return is_array( $rows ) ? $rows : array();
}
/**
* Fetches external result rows for the given attachment IDs.
*
* @param array<int, int> $ids Attachment IDs.
*
* @return array<mixed>
*/
private static function get_result_rows( array $ids ): array {
global $wpdb;
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, provider, match_count, created_at
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id IN ({$placeholders})",
...$ids
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
return is_array( $rows ) ? $rows : array();
}
/**
* Formats a mra_media_index row into WordPress privacy export data items.
*
* @param array<mixed, mixed> $row DB row.
* @param int $aid Attachment ID.
*
* @return array<int, array<string, string>>
*/
private static function format_index_row( array $row, int $aid ): array {
$file_name = is_string( $row['file_name'] ?? null ) ? $row['file_name'] : '';
$file_url = is_string( $row['file_url'] ?? null ) ? $row['file_url'] : '';
$status = is_string( $row['external_status'] ?? null ) ? $row['external_status'] : '';
$int_at = is_string( $row['internal_scanned_at'] ?? null ) ? $row['internal_scanned_at'] : '';
$ext_at = is_string( $row['external_scanned_at'] ?? null ) ? $row['external_scanned_at'] : '';
return array(
array(
'name' => __( 'Attachment ID', 'robotstxt-mediaaudit' ),
'value' => (string) $aid,
),
array(
'name' => __( 'Filename', 'robotstxt-mediaaudit' ),
'value' => $file_name,
),
array(
'name' => __( 'File URL', 'robotstxt-mediaaudit' ),
'value' => $file_url,
),
array(
'name' => __( 'External Scan Status', 'robotstxt-mediaaudit' ),
'value' => $status,
),
array(
'name' => __( 'Last Scanned (internal)', 'robotstxt-mediaaudit' ),
'value' => $int_at,
),
array(
'name' => __( 'Last Scanned (external)', 'robotstxt-mediaaudit' ),
'value' => $ext_at,
),
);
}
/**
* Formats a mra_external_results row into WordPress privacy export data items.
*
* @param array<mixed, mixed> $row DB row.
* @param int $aid Attachment ID.
* @param string $provider Provider slug.
*
* @return array<int, array<string, string>>
*/
private static function format_result_row( array $row, int $aid, string $provider ): array {
$mc_val = $row['match_count'] ?? null;
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
$created_at = is_string( $row['created_at'] ?? null ) ? $row['created_at'] : '';
return array(
array(
'name' => __( 'Attachment ID', 'robotstxt-mediaaudit' ),
'value' => (string) $aid,
),
array(
'name' => __( 'Provider', 'robotstxt-mediaaudit' ),
'value' => $provider,
),
array(
'name' => __( 'Match Count', 'robotstxt-mediaaudit' ),
'value' => (string) $match_count,
),
array(
'name' => __( 'Scanned At', 'robotstxt-mediaaudit' ),
'value' => $created_at,
),
);
}
}