hook_suffix = $suffix; add_action( 'load-' . $suffix, array( $this, 'handle_load' ) ); } /** * Fires before the admin header is sent, allowing headers to be set. * * Handles bulk actions that need to modify headers (CSV export) or redirect * (run_external_scan, purge_external_data) before any HTML output begins. * * @return void */ public function handle_load(): void { $action = $this->get_current_bulk_action(); if ( 'export_csv' === $action ) { $this->handle_export_csv( false ); } elseif ( 'export_csv_alerts' === $action ) { $this->handle_export_csv( true ); } elseif ( 'run_external_scan' === $action ) { $this->handle_run_external_scan(); } elseif ( 'purge_external_data' === $action ) { $this->handle_purge_external_data(); } } /** * Enqueues CSS and JS only on the Media Audit admin page. * * @param string $hook_suffix Current admin page hook suffix. * * @return void */ public function enqueue_assets( string $hook_suffix ): void { if ( '' !== $this->hook_suffix && $hook_suffix !== $this->hook_suffix ) { return; } $base = ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL . 'assets/'; $ver = ROBOTSTXT_MEDIAAUDIT_VERSION; wp_enqueue_style( 'mra-admin', $base . 'css/media-audit-admin.css', array(), $ver ); wp_enqueue_script( 'mra-admin', $base . 'js/media-audit-admin.js', array( 'jquery' ), $ver, true ); wp_localize_script( 'mra-admin', 'mraAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'mra_usage_details' ), 'i18n' => array( 'loading' => __( 'Loading…', 'robotstxt-mediaaudit' ), 'errorLoading' => __( 'Error loading details.', 'robotstxt-mediaaudit' ), 'noUsages' => __( 'This image is not used in any post.', 'robotstxt-mediaaudit' ), 'detailsTitle' => __( 'Usage Details', 'robotstxt-mediaaudit' ), ), ) ); } /** * Renders the full Media Audit admin page. * * Bulk actions (CSV export, external scan, purge) are intercepted earlier in * handle_load() via the load-{hook_suffix} action, before any HTML is sent. * * @return void */ public function render(): void { if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) ); } $table = new MediaListTable(); $table->prepare_items(); echo '
'; echo '

' . esc_html__( 'Media Audit', 'robotstxt-mediaaudit' ) . '

'; $this->render_dashboard_stats(); echo '
'; echo ''; $table->search_box( __( 'Search', 'robotstxt-mediaaudit' ), 'mra-search' ); $table->display(); echo '
'; // Modal overlay (hidden by default, opened via JS). echo ''; echo '
'; // .wrap } /** * AJAX handler: returns formatted HTML for the usage-detail modal. * * @return void */ public function ajax_usage_details(): void { check_ajax_referer( 'mra_usage_details', 'nonce' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ) ); } $attachment_id = isset( $_POST['attachment_id'] ) && is_string( $_POST['attachment_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['attachment_id'] ) ) : 0; if ( $attachment_id <= 0 ) { wp_send_json_error( array( 'message' => __( 'Invalid attachment ID.', 'robotstxt-mediaaudit' ) ) ); } $usages = MediaListTable::fetch_usages( array( $attachment_id ) ); $title = get_the_title( $attachment_id ); // Build usage section. $context_labels = array( 'featured' => __( 'Featured Image', 'robotstxt-mediaaudit' ), 'content' => __( 'Post Content', 'robotstxt-mediaaudit' ), 'meta' => __( 'Custom Field', 'robotstxt-mediaaudit' ), ); if ( empty( $usages ) ) { $usage_html = '

' . esc_html__( 'This image is not used in any post.', 'robotstxt-mediaaudit' ) . '

'; } else { $usage_html = ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; foreach ( $usages as $u ) { $pid_val = $u['post_id'] ?? null; $post_id = is_numeric( $pid_val ) ? (int) $pid_val : 0; $t_raw = $u['post_title'] ?? ''; $post_title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', $post_id ); $pt_val = $u['post_type'] ?? null; $post_type = is_string( $pt_val ) ? $pt_val : ''; $ctx_val = $u['context'] ?? null; $context = is_string( $ctx_val ) ? $ctx_val : ''; $status_val = $u['post_status'] ?? null; $status = is_string( $status_val ) ? $status_val : ''; $mk_val = $u['meta_key'] ?? null; $meta_key = is_string( $mk_val ) ? $mk_val : ''; $ctx_label = isset( $context_labels[ $context ] ) ? $context_labels[ $context ] : esc_html( $context ); if ( '' !== $meta_key ) { $ctx_label .= ' ' . esc_html( $meta_key ) . ''; } $edit_link = get_edit_post_link( $post_id ); $post_cell = $edit_link ? sprintf( '%s', esc_url( $edit_link ), esc_html( $post_title ) ) : esc_html( $post_title ); $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; $usage_html .= ''; } $usage_html .= '
' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '
' . $post_cell . '' . esc_html( $post_type ) . '' . $ctx_label . '' . esc_html( $status ) . '
'; } // Build external results section. $external_html = $this->build_external_results_html( $attachment_id ); // Combine sections; add headings only when both sections are present. if ( '' !== $external_html ) { $html = '

' . esc_html__( 'Internal Usage', 'robotstxt-mediaaudit' ) . '

'; $html .= $usage_html; $html .= $external_html; } else { $html = $usage_html; } wp_send_json_success( array( 'title' => esc_html( $title ? $title : sprintf( '#%d', $attachment_id ) ), 'html' => $html, ) ); } /** * Builds the HTML for the external scan results section of the detail modal. * * Returns an empty string when no external scan data exists for the attachment. * * @param int $attachment_id WordPress attachment ID. * * @return string */ private function build_external_results_html( int $attachment_id ): string { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $wpdb->prepare( "SELECT provider, match_count, top_domains, raw_response, created_at FROM {$wpdb->prefix}mra_external_results WHERE attachment_id = %d ORDER BY provider ASC", $attachment_id ), ARRAY_A ); if ( empty( $rows ) ) { return ''; } $provider_names = array( 'google_vision' => 'Google Cloud Vision', 'tineye' => 'TinEye', 'picdefense' => 'PicDefense', ); $html = '

' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '

'; foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $slug_val = $row['provider'] ?? null; $slug = is_string( $slug_val ) ? $slug_val : ''; $name = isset( $provider_names[ $slug ] ) ? $provider_names[ $slug ] : ucwords( str_replace( '_', ' ', $slug ) ); $mc_val = $row['match_count'] ?? null; $match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0; $at_val = $row['created_at'] ?? null; $scanned_at = is_string( $at_val ) ? $at_val : ''; $td_val = $row['top_domains'] ?? null; $td_raw = is_string( $td_val ) ? json_decode( $td_val, true ) : null; $domains = is_array( $td_raw ) ? $td_raw : array(); $rr_val = $row['raw_response'] ?? null; $raw = null; if ( is_string( $rr_val ) && '' !== $rr_val ) { $rr_decoded = json_decode( $rr_val, true ); $raw = is_array( $rr_decoded ) ? $rr_decoded : null; } $html .= '
'; $html .= '' . esc_html( $name ) . ' — '; if ( $match_count > 0 ) { $html .= esc_html( sprintf( /* translators: %d: number of external matches */ _n( '%d match', '%d matches', $match_count, 'robotstxt-mediaaudit' ), $match_count ) ); } else { $html .= esc_html__( 'No matches', 'robotstxt-mediaaudit' ); } if ( 'picdefense' === $slug && null !== $raw ) { $pd_data_raw = $raw['data'] ?? null; $pd_arr = is_array( $pd_data_raw ) ? $pd_data_raw : array(); $pd_data = isset( $pd_arr[0] ) && is_array( $pd_arr[0] ) ? $pd_arr[0] : null; if ( is_array( $pd_data ) ) { $pr_val = $pd_data['picrisk'] ?? null; $picrisk = is_string( $pr_val ) ? strtolower( $pr_val ) : ''; if ( '' !== $picrisk ) { $html .= ' ' . esc_html( ucfirst( $picrisk ) ) . ''; } } } if ( '' !== $scanned_at ) { $html .= ' ' . esc_html( $scanned_at ) . ''; } $html .= '
'; if ( ! empty( $domains ) ) { $alert_domains = array(); $other_domains = array(); foreach ( $domains as $domain => $count ) { if ( ! is_string( $domain ) ) { continue; } $classification = HostnameFilter::classify( $domain ); if ( 'alert' === $classification ) { $alert_domains[ $domain ] = is_numeric( $count ) ? (int) $count : 0; } elseif ( 'other' === $classification ) { $other_domains[ $domain ] = is_numeric( $count ) ? (int) $count : 0; } // Ignored domains are skipped entirely. } $visible_domains = array_merge( $alert_domains, $other_domains ); if ( ! empty( $visible_domains ) ) { $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; foreach ( $alert_domains as $domain => $count ) { $html .= sprintf( '', esc_html( $domain ), esc_html( (string) $count ), esc_html__( 'Alert', 'robotstxt-mediaaudit' ) ); } foreach ( $other_domains as $domain => $count ) { $html .= sprintf( '', esc_html( $domain ), esc_html( (string) $count ) ); } $html .= '
' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '
%s%s%s
%s%s
'; } } } // Consensus section: only when multiple providers have results. if ( count( $rows ) > 1 ) { $consensus = ResultsConsolidator::get_consensus_domains( $attachment_id ); if ( ! empty( $consensus ) ) { $html .= '
'; $html .= '' . esc_html__( 'Consensus Domains', 'robotstxt-mediaaudit' ) . ''; $html .= '
'; } } $html .= ''; return $html; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Renders the dashboard stats strip. * * @return void */ private function render_dashboard_stats(): void { $stats = self::get_dashboard_stats(); $scanned_pct = $stats['total_indexed'] > 0 ? round( $stats['total_scanned'] / $stats['total_indexed'] * 100 ) : 0; echo '
'; $this->stat_box( $stats['total_indexed'], __( 'In Index', 'robotstxt-mediaaudit' ), $stats['pending_index'] > 0 ? sprintf( /* translators: %d: count of images not yet indexed */ __( '%d not yet indexed', 'robotstxt-mediaaudit' ), $stats['pending_index'] ) : __( 'All indexed', 'robotstxt-mediaaudit' ) ); $this->stat_box( $stats['total_scanned'], __( 'Scanned', 'robotstxt-mediaaudit' ), sprintf( /* translators: %d: percentage */ __( '%d%% of index', 'robotstxt-mediaaudit' ), $scanned_pct ) ); $this->stat_box( $stats['total_used'], __( 'Used', 'robotstxt-mediaaudit' ), '' ); $this->stat_box( $stats['total_unused'], __( 'Unused', 'robotstxt-mediaaudit' ), '' ); if ( $stats['total_matches'] > 0 ) { $this->stat_box( $stats['total_matches'], __( 'External Matches', 'robotstxt-mediaaudit' ), '' ); } if ( $stats['total_alert'] > 0 ) { $this->stat_box( $stats['total_alert'], __( 'Alert', 'robotstxt-mediaaudit' ), '' ); } echo '
'; } /** * Outputs a single stat box. * * @param int $value Main number. * @param string $label Short label. * @param string $sub Optional sub-label. * * @return void */ private function stat_box( int $value, string $label, string $sub ): void { echo '
'; echo '' . esc_html( number_format_i18n( $value ) ) . ''; echo '' . esc_html( $label ) . ''; if ( '' !== $sub ) { echo '' . esc_html( $sub ) . ''; } echo '
'; } /** * Outputs the unified CSV export and terminates the request. * * One row per attachment. When $alerts_only is true, only attachments that * have at least one alert domain match are included. * * @param bool $alerts_only When true, export only attachments with alert domains. * * @return void */ private function handle_export_csv( bool $alerts_only ): void { check_admin_referer( 'bulk-mra-attachments' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ); } $ids = $this->collect_attachment_ids(); $rows = $this->build_unified_csv_rows( $ids, $alerts_only ); $suffix = $alerts_only ? '-alerts' : ''; $filename = 'media-audit' . $suffix . '-' . gmdate( 'Y-m-d' ) . '.csv'; header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); header( 'Pragma: no-cache' ); $out = fopen( 'php://output', 'w' ); if ( false === $out ) { wp_die( esc_html__( 'Could not open output stream.', 'robotstxt-mediaaudit' ) ); } // BOM for Excel UTF-8 compatibility. fwrite( $out, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite fputcsv( $out, array( __( 'Attachment ID', 'robotstxt-mediaaudit' ), __( 'Filename', 'robotstxt-mediaaudit' ), __( 'File URL', 'robotstxt-mediaaudit' ), __( 'MIME Type', 'robotstxt-mediaaudit' ), __( 'File Size (bytes)', 'robotstxt-mediaaudit' ), __( 'Internal Scan Date', 'robotstxt-mediaaudit' ), __( 'External Status', 'robotstxt-mediaaudit' ), __( 'Has Alert', 'robotstxt-mediaaudit' ), __( 'Usage Count', 'robotstxt-mediaaudit' ), __( 'Used In', 'robotstxt-mediaaudit' ), __( 'Google Vision — Matches', 'robotstxt-mediaaudit' ), __( 'TinEye — Matches', 'robotstxt-mediaaudit' ), __( 'PicDefense — Matches', 'robotstxt-mediaaudit' ), __( 'Alert Domains', 'robotstxt-mediaaudit' ), __( 'Other Domains', 'robotstxt-mediaaudit' ), __( 'Ignored Domains', 'robotstxt-mediaaudit' ), __( 'Dismissed', 'robotstxt-mediaaudit' ), __( 'Dismissed Notes', 'robotstxt-mediaaudit' ), ) ); foreach ( $rows as $row ) { fputcsv( $out, $row ); } fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } /** * Builds unified CSV rows (one per attachment) with internal and external data. * * Columns: ID, filename, URL, MIME type, file size, internal scan date, * external status, has alert, usage count, used-in titles, per-provider * match counts, and classified domain lists (alert / other / ignored). * * When $ids is empty all indexed attachments are exported. When $alerts_only * is true, only attachments with at least one alert domain match are included. * * @param array $ids Attachment IDs to export (empty = all). * @param bool $alerts_only Include only attachments with alert domains. * * @return array> */ private function build_unified_csv_rows( array $ids, bool $alerts_only ): array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare if ( empty( $ids ) ) { $index_rows = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}mra_media_index ORDER BY attachment_id ASC", ARRAY_A ); } else { $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); $index_rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders}) ORDER BY attachment_id ASC", ...$ids ), ARRAY_A ); } if ( ! is_array( $index_rows ) || empty( $index_rows ) ) { return array(); } $page_ids = array_map( static function ( $v ): int { return (int) $v; }, array_column( $index_rows, 'attachment_id' ) ); // Fetch usages. $usages = MediaListTable::fetch_usages( $page_ids ); $usage_titles = array(); foreach ( $usages as $u ) { $aid_val = $u['attachment_id'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; $t_raw = $u['post_title'] ?? ''; $pid_raw = $u['post_id'] ?? null; $title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', is_numeric( $pid_raw ) ? (int) $pid_raw : 0 ); $usage_titles[ $aid ][] = $title; } // Fetch external results (all providers) for these attachments. $ext_placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) ); $ext_rows = $wpdb->get_results( $wpdb->prepare( "SELECT attachment_id, provider, match_count, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$ext_placeholders})", ...$page_ids ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare /** * Per-attachment, per-provider data: match_count + merged domain→count. * * @var array}>> $ext_by_id */ $ext_by_id = array(); if ( is_array( $ext_rows ) ) { foreach ( $ext_rows as $er ) { if ( ! is_array( $er ) ) { continue; } $aid_val = $er['attachment_id'] ?? null; $eaid = is_numeric( $aid_val ) ? (int) $aid_val : 0; $prov_val = $er['provider'] ?? null; $provider = is_string( $prov_val ) ? $prov_val : ''; $mc_val = $er['match_count'] ?? null; $mc = is_numeric( $mc_val ) ? (int) $mc_val : 0; $td_val = $er['top_domains'] ?? null; $td = is_string( $td_val ) && '' !== $td_val ? json_decode( $td_val, true ) : null; if ( $eaid <= 0 || '' === $provider ) { continue; } if ( ! isset( $ext_by_id[ $eaid ][ $provider ] ) ) { $ext_by_id[ $eaid ][ $provider ] = array( 'match_count' => 0, 'domains' => array(), ); } $ext_by_id[ $eaid ][ $provider ]['match_count'] += $mc; if ( is_array( $td ) ) { foreach ( $td as $domain => $count ) { if ( ! is_string( $domain ) ) { continue; } $existing = $ext_by_id[ $eaid ][ $provider ]['domains'][ $domain ] ?? 0; $ext_by_id[ $eaid ][ $provider ]['domains'][ $domain ] = $existing + ( is_numeric( $count ) ? (int) $count : 0 ); } } } } // Fetch dismissed IDs and notes for CSV columns. $dismissed_ids = AlertsPage::get_dismissed_ids(); // phpcs:disable WordPress.DB.DirectDatabaseQuery $dismissed_notes_rows = $wpdb->get_results( "SELECT attachment_id, notes FROM {$wpdb->prefix}mra_dismissed_alerts", ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery $dismissed_notes = array(); if ( is_array( $dismissed_notes_rows ) ) { foreach ( $dismissed_notes_rows as $dn ) { if ( is_array( $dn ) ) { $dn_aid_raw = $dn['attachment_id'] ?? null; $dn_aid = is_numeric( $dn_aid_raw ) ? (int) $dn_aid_raw : 0; if ( $dn_aid > 0 ) { $dismissed_notes[ $dn_aid ] = is_string( $dn['notes'] ?? null ) ? (string) $dn['notes'] : ''; } } } } $known_providers = array( 'google_vision', 'tineye', 'picdefense' ); $output = array(); foreach ( $index_rows as $r ) { if ( ! is_array( $r ) ) { continue; } $aid_val = $r['attachment_id'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; // Aggregate domains across all providers for this attachment. $all_domains = array(); foreach ( $known_providers as $p ) { $p_domains = $ext_by_id[ $aid ][ $p ]['domains'] ?? array(); foreach ( $p_domains as $domain => $count ) { $existing = $all_domains[ $domain ] ?? 0; $all_domains[ $domain ] = $existing + $count; } } // Classify domains. $alert_list = array(); $other_list = array(); $ignored_list = array(); foreach ( array_keys( $all_domains ) as $domain ) { $class = HostnameFilter::classify( $domain ); if ( 'alert' === $class ) { $alert_list[] = $domain; } elseif ( 'ignored' === $class ) { $ignored_list[] = $domain; } else { $other_list[] = $domain; } } $has_alert = ! empty( $alert_list ); $is_dismissed = isset( $dismissed_ids[ $aid ] ); // Skip row if alerts_only and either no alert or already dismissed. if ( $alerts_only && ( ! $has_alert || $is_dismissed ) ) { continue; } $row_data = array( (string) $aid, is_string( $r['file_name'] ?? null ) ? (string) $r['file_name'] : '', is_string( $r['file_url'] ?? null ) ? (string) $r['file_url'] : '', is_string( $r['mime_type'] ?? null ) ? (string) $r['mime_type'] : '', is_numeric( $r['file_size'] ?? null ) ? (string) (int) $r['file_size'] : '', is_string( $r['internal_scanned_at'] ?? null ) ? (string) $r['internal_scanned_at'] : '', is_string( $r['external_status'] ?? null ) ? (string) $r['external_status'] : '', $has_alert ? __( 'Yes', 'robotstxt-mediaaudit' ) : __( 'No', 'robotstxt-mediaaudit' ), (string) count( $usage_titles[ $aid ] ?? array() ), implode( '; ', $usage_titles[ $aid ] ?? array() ), ); foreach ( $known_providers as $p ) { $row_data[] = isset( $ext_by_id[ $aid ][ $p ] ) ? (string) $ext_by_id[ $aid ][ $p ]['match_count'] : ''; } $row_data[] = implode( '; ', $alert_list ); $row_data[] = implode( '; ', $other_list ); $row_data[] = implode( '; ', $ignored_list ); $row_data[] = $is_dismissed ? __( 'Yes', 'robotstxt-mediaaudit' ) : __( 'No', 'robotstxt-mediaaudit' ); $row_data[] = $dismissed_notes[ $aid ] ?? ''; $output[] = $row_data; } return $output; } /** * Queues selected attachments for external scanning and redirects. * * @return void */ private function handle_run_external_scan(): void { check_admin_referer( 'bulk-mra-attachments' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ); } $ids = $this->collect_attachment_ids(); if ( ! empty( $ids ) ) { ExternalScanner::queue_attachments( $ids ); } else { ExternalScanner::schedule(); } wp_safe_redirect( add_query_arg( array( 'page' => 'robotstxt-mediaaudit', 'mra_notice' => 'scan_scheduled', ), admin_url( 'admin.php' ) ) ); exit; } /** * Purges external scan data for selected attachments and redirects. * * @return void */ private function handle_purge_external_data(): void { check_admin_referer( 'bulk-mra-attachments' ); if ( ! current_user_can( 'edit_others_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) ); } $ids = $this->collect_attachment_ids(); if ( ! empty( $ids ) ) { ExternalScanner::purge( $ids ); } wp_safe_redirect( add_query_arg( array( 'page' => 'robotstxt-mediaaudit', 'mra_notice' => 'purge_done', ), admin_url( 'admin.php' ) ) ); exit; } /** * Collects and sanitises attachment IDs from the current bulk action POST data. * * @return array */ private function collect_attachment_ids(): array { $ids = array(); // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- nonce verified by all callers. if ( isset( $_REQUEST['attachment_id'] ) && is_array( $_REQUEST['attachment_id'] ) ) { foreach ( $_REQUEST['attachment_id'] as $raw ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $id = is_numeric( $raw ) ? (int) $raw : 0; if ( $id > 0 ) { $ids[] = $id; } } } // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended return $ids; } /** * Determines the active bulk action from the form submission. * * @return string Action name, or '' if none. */ private function get_current_bulk_action(): string { // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended $action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; if ( '-1' === $action ) { $action = isset( $_REQUEST['action2'] ) && is_string( $_REQUEST['action2'] ) ? sanitize_key( wp_unslash( $_REQUEST['action2'] ) ) : ''; } // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended return $action; } /** * Queries and returns dashboard statistics. * * @return array{total_indexed: int, total_scanned: int, total_used: int, total_unused: int, total_matches: int, total_alert: int, pending_index: int} */ private static function get_dashboard_stats(): array { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $total_indexed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index" ); $total_scanned = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NOT NULL" ); $total_used = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT attachment_id) FROM {$wpdb->prefix}mra_media_usage" ); $total_matches = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'matches'" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery $total_alert = self::get_alert_attachment_count(); return array( 'total_indexed' => $total_indexed, 'total_scanned' => $total_scanned, 'total_used' => $total_used, 'total_unused' => max( 0, $total_indexed - $total_used ), 'total_matches' => $total_matches, 'total_alert' => $total_alert, 'pending_index' => AttachmentIndexer::get_pending_count(), ); } /** * Counts the number of unique attachments that have at least one alert domain * in their external scan results. * * @return int */ private static function get_alert_attachment_count(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( "SELECT attachment_id, top_domains FROM {$wpdb->prefix}mra_external_results", ARRAY_A ); if ( ! is_array( $rows ) ) { return 0; } // Group top_domains by attachment_id and check for alert domains. $merged = array(); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $aid_val = $row['attachment_id'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; if ( $aid <= 0 ) { continue; } $td_val = $row['top_domains'] ?? null; $td_raw = is_string( $td_val ) ? json_decode( $td_val, true ) : null; $domains = is_array( $td_raw ) ? $td_raw : array(); if ( ! isset( $merged[ $aid ] ) ) { $merged[ $aid ] = array(); } foreach ( $domains as $domain => $count ) { if ( ! is_string( $domain ) ) { continue; } $c = is_numeric( $count ) ? (int) $count : 0; if ( isset( $merged[ $aid ][ $domain ] ) ) { $merged[ $aid ][ $domain ] += $c; } else { $merged[ $aid ][ $domain ] = $c; } } } $dismissed = AlertsPage::get_dismissed_ids(); $alert_count = 0; foreach ( $merged as $aid => $domains ) { if ( isset( $dismissed[ $aid ] ) ) { continue; } if ( HostnameFilter::has_alert_domains( $domains ) ) { ++$alert_count; } } return $alert_count; } }