This commit is contained in:
Javier Casares 2026-06-03 06:29:56 +00:00
commit 98b1cf0f3b
17 changed files with 2968 additions and 1313 deletions

View file

@ -7,6 +7,8 @@
namespace MediaRightsAudit\Admin;
use MediaRightsAudit\External\HostnameFilter;
/**
* Registers and renders the hidden attachment detail admin page.
*
@ -231,6 +233,10 @@ class AttachmentDetailPage {
/**
* Fetches and renders the external scan results for a given attachment.
*
* After rendering all per-provider sections, aggregates top_domains across
* all providers and passes them to render_domain_classification() for a
* consolidated view grouped by alert / other / ignored status.
*
* @param int $attachment_id Attachment post ID.
*
* @return void
@ -241,7 +247,7 @@ class AttachmentDetailPage {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT provider, match_count, raw_response, created_at
"SELECT provider, match_count, top_domains, raw_response, created_at
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id = %d
ORDER BY provider ASC",
@ -261,6 +267,8 @@ class AttachmentDetailPage {
'picdefense' => 'PicDefense',
);
$merged_domains = array();
foreach ( $rows as $row ) {
if ( ! is_array( $row ) ) {
continue;
@ -273,6 +281,9 @@ class AttachmentDetailPage {
$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 ) {
@ -280,6 +291,19 @@ class AttachmentDetailPage {
$raw = is_array( $decoded ) ? $decoded : null;
}
// Accumulate top_domains across all providers.
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$c = is_numeric( $count ) ? (int) $count : 0;
if ( isset( $merged_domains[ $domain ] ) ) {
$merged_domains[ $domain ] += $c;
} else {
$merged_domains[ $domain ] = $c;
}
}
echo '<h3>';
echo esc_html( $name );
echo ' &mdash; ';
@ -311,6 +335,87 @@ class AttachmentDetailPage {
}
}
}
// Render consolidated domain classification section.
if ( ! empty( $merged_domains ) ) {
$this->render_domain_classification( $merged_domains );
}
}
/**
* Renders a domain classification summary grouped into Alert, Other, and Ignored sections.
*
* Each section is rendered only when non-empty. Alert domains are shown with
* a warning note about copyright risk.
*
* @param array<string, int> $domains Map of domain => total occurrence count.
*
* @return void
*/
private function render_domain_classification( array $domains ): void {
$alert_domains = array();
$other_domains = array();
$ignored_domains = array();
foreach ( $domains as $domain => $count ) {
$classification = HostnameFilter::classify( $domain );
if ( 'alert' === $classification ) {
$alert_domains[ $domain ] = $count;
} elseif ( 'ignored' === $classification ) {
$ignored_domains[ $domain ] = $count;
} else {
$other_domains[ $domain ] = $count;
}
}
if ( ! empty( $alert_domains ) ) {
echo '<h3>' . esc_html__( 'Alert Domains', 'robotstxt-mediaaudit' ) . '</h3>';
echo '<p class="description">' . esc_html__( 'These domains are associated with copyright-protected content and may indicate a copyright risk.', 'robotstxt-mediaaudit' ) . '</p>';
echo '<table class="widefat mra-detail-table">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $alert_domains as $domain => $count ) {
echo '<tr>';
echo '<td>' . esc_html( $domain ) . '</td>';
echo '<td>' . esc_html( (string) $count ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
if ( ! empty( $other_domains ) ) {
echo '<h3>' . esc_html__( 'Other Domains', 'robotstxt-mediaaudit' ) . '</h3>';
echo '<table class="widefat mra-detail-table">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $other_domains as $domain => $count ) {
echo '<tr>';
echo '<td>' . esc_html( $domain ) . '</td>';
echo '<td>' . esc_html( (string) $count ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
if ( ! empty( $ignored_domains ) ) {
echo '<h3>' . esc_html__( 'Ignored Domains', 'robotstxt-mediaaudit' ) . '</h3>';
echo '<table class="widefat mra-detail-table">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $ignored_domains as $domain => $count ) {
echo '<tr>';
echo '<td>' . esc_html( $domain ) . '</td>';
echo '<td>' . esc_html( (string) $count ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
}
/**

View file

@ -9,6 +9,7 @@ namespace MediaRightsAudit\Admin;
use MediaRightsAudit\Admin\AttachmentDetailPage;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\External\HostnameFilter;
use MediaRightsAudit\External\ResultsConsolidator;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
@ -65,9 +66,9 @@ class AuditPage {
$action = $this->get_current_bulk_action();
if ( 'export_csv' === $action ) {
$this->handle_export_csv();
} elseif ( 'export_external_csv' === $action ) {
$this->handle_export_external_csv();
$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 ) {
@ -346,22 +347,51 @@ class AuditPage {
$html .= '</div>';
if ( ! empty( $domains ) ) {
$html .= '<table class="widefat mra-domains-table">';
$html .= '<thead><tr>';
$html .= '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '</tr></thead><tbody>';
$alert_domains = array();
$other_domains = array();
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$html .= sprintf(
'<tr><td>%s</td><td>%s</td></tr>',
esc_html( $domain ),
esc_html( (string) ( is_numeric( $count ) ? (int) $count : 0 ) )
);
$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 .= '<table class="widefat mra-domains-table">';
$html .= '<thead><tr>';
$html .= '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '<th>' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '</th>';
$html .= '</tr></thead><tbody>';
foreach ( $alert_domains as $domain => $count ) {
$html .= sprintf(
'<tr><td>%s</td><td>%s</td><td><span class="mra-badge mra-badge-alert">%s</span></td></tr>',
esc_html( $domain ),
esc_html( (string) $count ),
esc_html__( 'Alert', 'robotstxt-mediaaudit' )
);
}
foreach ( $other_domains as $domain => $count ) {
$html .= sprintf(
'<tr><td>%s</td><td>%s</td><td></td></tr>',
esc_html( $domain ),
esc_html( (string) $count )
);
}
$html .= '</tbody></table>';
}
$html .= '</tbody></table>';
}
}
@ -452,6 +482,14 @@ class AuditPage {
);
}
if ( $stats['total_alert'] > 0 ) {
$this->stat_box(
$stats['total_alert'],
__( 'Alert', 'robotstxt-mediaaudit' ),
''
);
}
echo '</div>';
}
@ -475,21 +513,26 @@ class AuditPage {
}
/**
* Outputs CSV for selected attachment IDs and terminates the request.
* 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(): void {
private function handle_export_csv( bool $alerts_only ): void {
check_admin_referer( 'bulk-mra-attachments' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
}
$ids = $this->collect_attachment_ids();
$rows = $this->build_csv_rows( $ids );
$filename = 'media-audit-' . gmdate( 'Y-m-d' ) . '.csv';
$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 . '"' );
@ -511,9 +554,17 @@ class AuditPage {
__( '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 (post titles)', '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' ),
)
);
@ -526,136 +577,188 @@ class AuditPage {
}
/**
* Outputs an external-results CSV for selected attachment IDs and terminates.
* Builds unified CSV rows (one per attachment) with internal and external data.
*
* One row per attachment × provider pair. Attachments with no results still
* appear with empty provider columns.
* 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).
*
* @return void
*/
private function handle_export_external_csv(): void {
check_admin_referer( 'bulk-mra-attachments' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
}
$ids = $this->collect_attachment_ids();
$rows = $this->build_external_csv_rows( $ids );
$filename = 'media-audit-external-' . 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' ) );
}
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' ),
__( 'External Status', 'robotstxt-mediaaudit' ),
__( 'Last Scanned', 'robotstxt-mediaaudit' ),
__( 'Provider', 'robotstxt-mediaaudit' ),
__( 'Match Count', 'robotstxt-mediaaudit' ),
__( 'Top Domains', 'robotstxt-mediaaudit' ),
)
);
foreach ( $rows as $row ) {
fputcsv( $out, $row );
}
fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}
/**
* Builds external-results CSV rows for the given attachment IDs.
* 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.
*
* Each row represents one attachment × provider combination. Attachments with
* no provider results are included with empty provider columns. If $ids is
* empty, all indexed attachments are exported.
*
* @param array<int, int> $ids Attachment IDs to export (empty = all).
* @param array<int, int> $ids Attachment IDs to export (empty = all).
* @param bool $alerts_only Include only attachments with alert domains.
*
* @return array<int, array<int, string>>
*/
private function build_external_csv_rows( array $ids ): 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 ) ) {
$rows = $wpdb->get_results(
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
i.external_scanned_at, er.provider, er.match_count, er.top_domains
FROM {$wpdb->prefix}mra_media_index i
LEFT JOIN {$wpdb->prefix}mra_external_results er ON er.attachment_id = i.attachment_id
ORDER BY i.attachment_id ASC, er.provider ASC",
$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' ) );
$rows = $wpdb->get_results(
$index_rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
i.external_scanned_at, er.provider, er.match_count, er.top_domains
FROM {$wpdb->prefix}mra_media_index i
LEFT JOIN {$wpdb->prefix}mra_external_results er ON er.attachment_id = i.attachment_id
WHERE i.attachment_id IN ({$placeholders})
ORDER BY i.attachment_id ASC, er.provider ASC",
"SELECT * FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders}) ORDER BY attachment_id ASC",
...$ids
),
ARRAY_A
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( ! $rows ) {
if ( ! is_array( $index_rows ) || empty( $index_rows ) ) {
return array();
}
$output = array();
$page_ids = array_map(
static function ( $v ): int {
return (int) $v;
},
array_column( $index_rows, 'attachment_id' )
);
foreach ( $rows as $r ) {
$aid_raw = $r['attachment_id'] ?? '';
$scanned_raw = $r['external_scanned_at'] ?? '';
$domains_raw = $r['top_domains'] ?? '';
$mc_raw = $r['match_count'] ?? '';
// 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;
}
$domains_decoded = is_string( $domains_raw ) && '' !== $domains_raw
? json_decode( $domains_raw, true )
: array();
$domain_parts = array();
if ( is_array( $domains_decoded ) ) {
foreach ( $domains_decoded as $d ) {
if ( is_string( $d ) ) {
$domain_parts[] = $d;
// 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<int, array<string, array{match_count: int, domains: array<string, int>}>> $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 );
}
}
}
$domains_str = implode( '; ', $domain_parts );
}
$output[] = array(
is_numeric( $aid_raw ) ? (string) (int) $aid_raw : '',
$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 );
if ( $alerts_only && ! $has_alert ) {
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'] : '',
is_string( $scanned_raw ) ? $scanned_raw : '',
is_string( $r['provider'] ?? null ) ? (string) $r['provider'] : '',
is_numeric( $mc_raw ) ? (string) (int) $mc_raw : '',
$domains_str,
$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 );
$output[] = $row_data;
}
return $output;
@ -745,87 +848,6 @@ class AuditPage {
return $ids;
}
/**
* Builds CSV data rows for the given attachment IDs.
*
* If $ids is empty, exports all indexed attachments.
*
* @param array<int, int> $ids Attachment IDs to export (empty = all).
*
* @return array<int, array<int, string>>
*/
private function build_csv_rows( array $ids ): 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
);
}
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( ! $index_rows ) {
return array();
}
$page_ids = array_map(
static function ( $v ) {
return (int) $v;
},
array_column( $index_rows, 'attachment_id' )
);
$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;
}
$output = array();
foreach ( $index_rows as $r ) {
$aid = (int) ( $r['attachment_id'] ?? 0 );
$titles = isset( $usage_titles[ $aid ] ) ? implode( '; ', $usage_titles[ $aid ] ) : '';
$fn_raw = $r['file_name'] ?? '';
$url_raw = $r['file_url'] ?? '';
$mt_raw = $r['mime_type'] ?? '';
$fs_raw = $r['file_size'] ?? '';
$es_raw = $r['external_status'] ?? '';
$output[] = array(
(string) $aid,
is_string( $fn_raw ) ? $fn_raw : '',
is_string( $url_raw ) ? $url_raw : '',
is_string( $mt_raw ) ? $mt_raw : '',
is_string( $fs_raw ) ? $fs_raw : '',
is_string( $es_raw ) ? $es_raw : '',
(string) count( $usage_titles[ $aid ] ?? array() ),
$titles,
);
}
return $output;
}
/**
* Determines the active bulk action from the form submission.
*
@ -844,7 +866,7 @@ class AuditPage {
/**
* Queries and returns dashboard statistics.
*
* @return array{total_indexed: int, total_scanned: int, total_used: int, total_unused: int, total_matches: int, pending_index: int}
* @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;
@ -856,13 +878,77 @@ class AuditPage {
$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;
}
}
}
$alert_count = 0;
foreach ( $merged as $domains ) {
if ( HostnameFilter::has_alert_domains( $domains ) ) {
++$alert_count;
}
}
return $alert_count;
}
}

View file

@ -8,6 +8,7 @@
namespace MediaRightsAudit\Admin;
use MediaRightsAudit\Admin\AttachmentDetailPage;
use MediaRightsAudit\External\HostnameFilter;
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
@ -83,7 +84,7 @@ class MediaListTable extends \WP_List_Table {
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
'export_csv' => __( 'Export CSV', 'robotstxt-mediaaudit' ),
'export_external_csv' => __( 'Export External Results CSV', 'robotstxt-mediaaudit' ),
'export_csv_alerts' => __( 'Export CSV (Alerts only)', 'robotstxt-mediaaudit' ),
);
}
@ -279,6 +280,49 @@ class MediaListTable extends \WP_List_Table {
$es_val = $item['external_status'];
$status = is_string( $es_val ) ? $es_val : '';
// Check for alert domains first.
$top_domains_raw = isset( $item['top_domains_data'] ) && is_array( $item['top_domains_data'] )
? $item['top_domains_data']
: array();
$top_domains_data = array();
foreach ( $top_domains_raw as $td_key => $td_val ) {
if ( is_string( $td_key ) && is_int( $td_val ) ) {
$top_domains_data[ $td_key ] = $td_val;
}
}
$has_alert = ! empty( $top_domains_data ) && HostnameFilter::has_alert_domains( $top_domains_data );
if ( $has_alert ) {
$out = sprintf(
'<span class="mra-badge mra-badge-alert">%s</span>',
esc_html__( 'Alert', 'robotstxt-mediaaudit' )
);
// Sum counts for non-ignored domains.
$filtered_count = 0;
foreach ( $top_domains_data as $domain => $count ) {
if ( ! HostnameFilter::is_ignored( $domain ) ) {
$filtered_count += $count;
}
}
if ( $filtered_count > 0 ) {
$out .= sprintf(
' <span class="mra-badge mra-badge-match-count">%s</span>',
esc_html( number_format_i18n( $filtered_count ) )
);
}
$aid_val = $item['attachment_id'];
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
$out .= sprintf(
' <a href="#" class="mra-view-details" data-id="%d">%s</a>',
$aid,
esc_html__( 'View Results', 'robotstxt-mediaaudit' )
);
return $out;
}
$labels = array(
'pending' => __( 'Pending', 'robotstxt-mediaaudit' ),
'queued' => __( 'Queued', 'robotstxt-mediaaudit' ),
@ -385,6 +429,11 @@ class MediaListTable extends \WP_List_Table {
// External status filter.
echo '<select name="mra_external_status">';
echo '<option value="">' . esc_html__( 'All statuses', 'robotstxt-mediaaudit' ) . '</option>';
printf(
'<option value="alert"%s>%s</option>',
selected( $current_status, 'alert', false ),
esc_html__( 'Alert', 'robotstxt-mediaaudit' )
);
foreach ( self::EXTERNAL_STATUSES as $s ) {
printf(
'<option value="%s"%s>%s</option>',
@ -456,6 +505,13 @@ class MediaListTable extends \WP_List_Table {
$show_unused = ! empty( $_REQUEST['mra_unused'] );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// Detect alert filter before normalising external_status.
$filter_alert = ( 'alert' === $external_status );
if ( $filter_alert ) {
// Reuse existing matches WHERE logic to narrow the candidate set.
$external_status = 'matches';
}
if ( ! in_array( $external_status, self::EXTERNAL_STATUSES, true ) ) {
$external_status = '';
}
@ -485,8 +541,6 @@ class MediaListTable extends \WP_List_Table {
$where = implode( ' AND ', $where_parts );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
if ( 'usage_count' === $orderby ) {
$order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}";
} elseif ( 'external_scanned_at' === $orderby ) {
@ -496,30 +550,110 @@ class MediaListTable extends \WP_List_Table {
$order_sql = "i.attachment_id {$order}";
}
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
$items_sql = "SELECT i.*,
$select_sql = "SELECT i.*,
(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) AS usage_count,
(SELECT COALESCE(SUM(er.match_count), 0) FROM {$wpdb->prefix}mra_external_results er WHERE er.attachment_id = i.attachment_id) AS total_match_count
FROM {$wpdb->prefix}mra_media_index i
WHERE {$where}
ORDER BY {$order_sql}
LIMIT %d OFFSET %d";
FROM {$wpdb->prefix}mra_media_index i";
$count_prepared = empty( $prepare_args )
? $count_sql
: $wpdb->prepare( $count_sql, ...$prepare_args );
$rows = array();
$total = 0;
$items_prepared = $wpdb->prepare(
$items_sql,
...array_merge( $prepare_args, array( $per_page, $offset ) )
);
if ( $filter_alert ) {
// Fetch ALL matching rows (no LIMIT), then PHP-filter by alert domains.
$all_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql}";
$total = (int) $wpdb->get_var( $count_prepared );
$rows = $wpdb->get_results( $items_prepared, ARRAY_A );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$all_prepared = empty( $prepare_args )
? $all_sql
: $wpdb->prepare( $all_sql, ...$prepare_args );
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$all_rows = $wpdb->get_results( $all_prepared, ARRAY_A );
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
if ( ! $rows ) {
if ( ! is_array( $all_rows ) ) {
$all_rows = array();
}
// Bulk-fetch top_domains for all candidate rows.
$all_ids = array_map( 'intval', array_column( $all_rows, 'attachment_id' ) );
$top_domains_by_id = self::fetch_top_domains( $all_ids );
// PHP-filter: keep only rows that have at least one alert domain.
$filtered = array();
foreach ( $all_rows as $r ) {
if ( ! is_array( $r ) ) {
continue;
}
$aid_val = $r['attachment_id'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
if ( HostnameFilter::has_alert_domains( $top_domains_by_id[ $aid ] ?? array() ) ) {
$filtered[] = $r;
}
}
$total = count( $filtered );
$rows = array_slice( $filtered, $offset, $per_page );
// Attach top_domains_data for the page slice.
foreach ( $rows as &$row ) {
$rid_val = $row['attachment_id'] ?? null;
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
$row['top_domains_data'] = $top_domains_by_id[ $rid ] ?? array();
}
unset( $row );
} else {
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
$items_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql} LIMIT %d OFFSET %d";
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$count_prepared = empty( $prepare_args )
? $count_sql
: $wpdb->prepare( $count_sql, ...$prepare_args );
$items_prepared = $wpdb->prepare(
$items_sql,
...array_merge( $prepare_args, array( $per_page, $offset ) )
);
$total = (int) $wpdb->get_var( $count_prepared );
$rows = $wpdb->get_results( $items_prepared, ARRAY_A );
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
if ( ! is_array( $rows ) ) {
$rows = array();
}
// Bulk-fetch top_domains only for rows with external_status = 'matches'.
$matches_ids = array();
foreach ( $rows as $r ) {
if ( ! is_array( $r ) ) {
continue;
}
$es_val = $r['external_status'] ?? null;
if ( 'matches' === $es_val ) {
$aid_val = $r['attachment_id'] ?? null;
if ( is_numeric( $aid_val ) ) {
$matches_ids[] = (int) $aid_val;
}
}
}
$top_domains_by_id = empty( $matches_ids )
? array()
: self::fetch_top_domains( $matches_ids );
foreach ( $rows as &$row ) {
if ( ! is_array( $row ) ) {
continue;
}
$rid_val = $row['attachment_id'] ?? null;
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
$row['top_domains_data'] = $top_domains_by_id[ $rid ] ?? array();
}
unset( $row );
}
if ( empty( $rows ) ) {
$this->items = array();
} else {
// Bulk-load usages for this page (avoid N+1).
@ -533,6 +667,9 @@ class MediaListTable extends \WP_List_Table {
}
foreach ( $rows as &$row ) {
if ( ! is_array( $row ) ) {
continue;
}
$rid_val = $row['attachment_id'] ?? null;
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
$row['usages_data'] = $usage_by[ $rid ] ?? array();
@ -562,6 +699,74 @@ class MediaListTable extends \WP_List_Table {
// Public query helpers
// -------------------------------------------------------------------------
/**
* Fetches and merges top_domains for a set of attachment IDs.
* Multiple providers for the same attachment have their domain counts summed.
*
* @param array<int, int> $attachment_ids Attachment IDs to look up.
*
* @return array<int, array<string, int>> attachment_id domain count
*/
public static function fetch_top_domains( array $attachment_ids ): array {
global $wpdb;
if ( empty( $attachment_ids ) ) {
return array();
}
$placeholders = implode( ',', array_fill( 0, count( $attachment_ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$placeholders})",
...$attachment_ids
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
if ( ! is_array( $rows ) ) {
return array();
}
$result = 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( $result[ $aid ] ) ) {
$result[ $aid ] = array();
}
foreach ( $domains as $domain => $count ) {
if ( ! is_string( $domain ) ) {
continue;
}
$c = is_numeric( $count ) ? (int) $count : 0;
if ( isset( $result[ $aid ][ $domain ] ) ) {
$result[ $aid ][ $domain ] += $c;
} else {
$result[ $aid ][ $domain ] = $c;
}
}
}
return $result;
}
/**
* Fetches usage rows for a set of attachment IDs in one query.
*

View file

@ -8,9 +8,11 @@
namespace MediaRightsAudit\Admin;
/**
* Registers and renders the plugin Settings page.
* Registers and renders the plugin Settings page with tab navigation.
*
* Settings are stored in a single option array: robotstxt_mediaaudit_settings.
* All tabs share a single option (robotstxt_mediaaudit_settings). A hidden
* _tab sentinel in each form submission tells sanitize() which fields to
* process, so saving one tab never clears another tab's data.
*/
class Settings {
@ -24,6 +26,198 @@ class Settings {
*/
const OPTION_NAME = 'robotstxt_mediaaudit_settings';
/**
* Default alert hostnames seeded on first activation.
*
* @var list<string>
*/
private const DEFAULT_FILTER_INCLUDE = array(
'123rf.com',
'afp.com',
'alamy.com',
'arcangel.com',
'artgrid.io',
'audioblocks.com',
'auroraphotos.com',
'backgridusa.com',
'bigstockphoto.com',
'blendimages.com',
'bridgemanimages.com',
'canstockphoto.com',
'cavanimages.com',
'creativemarket.com',
'crestock.com',
'cultura-rm.com',
'depositphotos.com',
'designbundles.net',
'diomedia.com',
'dreamstime.com',
'elements.envato.com',
'epa.eu',
'filmsupply.com',
'flaticon.com',
'fotolia.com',
'freepik.com',
'gallerystock.com',
'gettyimages.com',
'goffphotos.com',
'granger.com',
'graphicstock.com',
'imago-images.de',
'istockphoto.com',
'maryevans.com',
'mindenpictures.com',
'mintimages.com',
'mostphotos.com',
'naturepl.com',
'newsroom.ap.org',
'nhpa.co.uk',
'offset.shutterstock.com',
'pacificpressagency.com',
'panthermedia.net',
'photoshot.com',
'pictures.reuters.com',
'pixtastock.com',
'plainpicture.com',
'pond5.com',
'robertharding.com',
'sciencephoto.com',
'shutterstock.com',
'splashnews.com',
'stock.adobe.com',
'stocksy.com',
'storyblocks.com',
'trevillion.com',
'vecteezy.com',
'videoblocks.com',
'wenn.com',
'yayimages.com',
'zumapress.com',
);
/**
* Default ignored hostnames seeded on first activation.
*
* @var list<string>
*/
private const DEFAULT_FILTER_EXCLUDE = array(
'9gag.com',
'artstation.com',
'bandcamp.com',
'behance.net',
'blogger.com',
'bsky.app',
'dailymotion.com',
'deviantart.com',
'discord.com',
'douyin.com',
'dribbble.com',
'facebook.com',
'fb.com',
'flickr.com',
'gfycat.com',
'giphy.com',
'imgur.com',
'instagram.com',
'linkedin.com',
'livejournal.com',
'loom.com',
'mastodon.social',
'medium.com',
'notion.site',
'onlyfans.com',
'patreon.com',
'periscope.tv',
'pinterest.com',
'qq.com',
'reddit.com',
'snapchat.com',
'soundcloud.com',
'spotify.com',
'squarespace.com',
'streamable.com',
'substack.com',
'telegram.org',
'tenor.com',
'threads.net',
'tiktok.com',
'tumblr.com',
'twitch.tv',
'twitter.com',
'vimeo.com',
'vk.com',
'weebly.com',
'weibo.com',
'whatsapp.com',
'wix.com',
'wordpress.com',
'wordpress.org',
'x.com',
'xiaohongshu.com',
'youtube.com',
'zhihu.com',
);
/**
* Valid tab keys (order defines display order).
*
* @var list<string>
*/
private const TAB_KEYS = array( 'general', 'api', 'filters', 'external' );
/**
* Seeds the default filter lists into the option when they have not been set yet.
*
* Called once on plugin activation. Does nothing if the keys already exist,
* so existing user customisations are never overwritten.
*
* @return void
*/
public static function maybe_seed_defaults(): void {
$raw = get_option( self::OPTION_NAME, array() );
$options = is_array( $raw ) ? $raw : array();
$changed = false;
if ( ! array_key_exists( 'filter_include', $options ) ) {
$options['filter_include'] = self::DEFAULT_FILTER_INCLUDE;
$changed = true;
}
if ( ! array_key_exists( 'filter_exclude', $options ) ) {
$options['filter_exclude'] = self::DEFAULT_FILTER_EXCLUDE;
$changed = true;
}
if ( $changed ) {
update_option( self::OPTION_NAME, $options );
}
}
/**
* Returns translated tab labels keyed by tab ID.
*
* @return array<string, string>
*/
private function get_tabs(): array {
return array(
'general' => __( 'General', 'robotstxt-mediaaudit' ),
'api' => __( 'API Credentials', 'robotstxt-mediaaudit' ),
'filters' => __( 'Filters', 'robotstxt-mediaaudit' ),
'external' => __( 'External Scanning', 'robotstxt-mediaaudit' ),
);
}
/**
* Returns the active tab key derived from the current request.
*
* @return string
*/
private function get_active_tab(): string {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$raw = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'general';
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'general';
}
/**
* Registers all settings, sections, and fields via the WordPress Settings API.
*
@ -42,33 +236,35 @@ class Settings {
)
);
// --- General tab ---
add_settings_section(
'mra_general',
__( 'General', 'robotstxt-mediaaudit' ),
'__return_false',
'robotstxt-mediaaudit-settings'
'',
array( $this, 'section_general_status' ),
'mra-settings-general'
);
add_settings_field(
'delete_on_uninstall',
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
array( $this, 'field_delete_on_uninstall' ),
'robotstxt-mediaaudit-settings',
'mra-settings-general',
'mra_general'
);
// --- API Credentials tab ---
add_settings_section(
'mra_api_credentials',
__( 'API Credentials', 'robotstxt-mediaaudit' ),
'',
'__return_false',
'robotstxt-mediaaudit-settings'
'mra-settings-api'
);
add_settings_field(
'google_vision_api_key',
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_google_vision_api_key' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
@ -76,7 +272,7 @@ class Settings {
'tineye_api_key',
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_tineye_api_key' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
@ -84,7 +280,7 @@ class Settings {
'picdefense_user_id',
__( 'PicDefense User ID', 'robotstxt-mediaaudit' ),
array( $this, 'field_picdefense_user_id' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
@ -92,22 +288,54 @@ class Settings {
'picdefense_api_key',
__( 'PicDefense API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_picdefense_api_key' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
// --- Filters tab ---
add_settings_section(
'mra_filters_include',
__( 'Alert Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'section_filters_include' ),
'mra-settings-filters'
);
add_settings_field(
'filter_include',
__( 'Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'field_filter_include' ),
'mra-settings-filters',
'mra_filters_include'
);
add_settings_section(
'mra_filters_exclude',
__( 'Ignored Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'section_filters_exclude' ),
'mra-settings-filters'
);
add_settings_field(
'filter_exclude',
__( 'Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'field_filter_exclude' ),
'mra-settings-filters',
'mra_filters_exclude'
);
// --- External Scanning tab ---
add_settings_section(
'mra_external_scanning',
__( 'External Scanning', 'robotstxt-mediaaudit' ),
'',
'__return_false',
'robotstxt-mediaaudit-settings'
'mra-settings-external'
);
add_settings_field(
'external_batch_size',
__( 'Batch Size', 'robotstxt-mediaaudit' ),
array( $this, 'field_external_batch_size' ),
'robotstxt-mediaaudit-settings',
'mra-settings-external',
'mra_external_scanning'
);
@ -115,7 +343,7 @@ class Settings {
'rate_limit_per_minute',
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
array( $this, 'field_rate_limit_per_minute' ),
'robotstxt-mediaaudit-settings',
'mra-settings-external',
'mra_external_scanning'
);
}
@ -123,42 +351,214 @@ class Settings {
/**
* Sanitises the settings array on save.
*
* Reads the submitted _tab sentinel to determine which fields are present
* and merges them onto the existing stored values, leaving all other tabs
* untouched.
*
* @param mixed $input Raw POST input.
*
* @return array<string, mixed>
*/
public function sanitize( $input ): array {
$output = array();
$raw = get_option( self::OPTION_NAME, array() );
$current = is_array( $raw ) ? $raw : array();
$output = $current;
if ( ! is_array( $input ) ) {
return $output;
}
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
$tab_raw = $input['_tab'] ?? '';
$tab = is_string( $tab_raw ) ? sanitize_key( $tab_raw ) : '';
$key_val = $input['google_vision_api_key'] ?? null;
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
if ( 'general' === $tab ) {
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
}
$tineye_val = $input['tineye_api_key'] ?? null;
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
if ( 'api' === $tab ) {
$key_val = $input['google_vision_api_key'] ?? null;
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
$pd_uid_val = $input['picdefense_user_id'] ?? null;
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
$tineye_val = $input['tineye_api_key'] ?? null;
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
$pd_key_val = $input['picdefense_api_key'] ?? null;
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
$pd_uid_val = $input['picdefense_user_id'] ?? null;
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
$batch_val = $input['external_batch_size'] ?? null;
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
$pd_key_val = $input['picdefense_api_key'] ?? null;
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
}
$rl_val = $input['rate_limit_per_minute'] ?? null;
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
if ( 'filters' === $tab ) {
$output['filter_include'] = $this->sanitize_hostname_list( $input['filter_include'] ?? '' );
$output['filter_exclude'] = $this->sanitize_hostname_list( $input['filter_exclude'] ?? '' );
$overlap = array_intersect( $output['filter_include'], $output['filter_exclude'] );
if ( ! empty( $overlap ) ) {
$output['filter_exclude'] = array_values( array_diff( $output['filter_exclude'], $overlap ) );
add_settings_error(
self::OPTION_NAME,
'mra_filter_overlap',
sprintf(
/* translators: %s: comma-separated list of hostnames */
__( 'The following hostnames were removed from the exclusion list because they already appear in the alert list: %s', 'robotstxt-mediaaudit' ),
esc_html( implode( ', ', $overlap ) )
),
'warning'
);
}
}
if ( 'external' === $tab ) {
$batch_val = $input['external_batch_size'] ?? null;
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
$rl_val = $input['rate_limit_per_minute'] ?? null;
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
}
unset( $output['_tab'] );
return $output;
}
/**
* Sanitises a newline-separated list of hostnames into a deduplicated array.
*
* Accepts plain hostnames (example.com) and explicit wildcard prefixes
* (*.example.com). A plain hostname implicitly covers all its subdomains
* at match time.
*
* @param mixed $raw Raw textarea value.
*
* @return list<string>
*/
private function sanitize_hostname_list( mixed $raw ): array {
if ( ! is_string( $raw ) ) {
return array();
}
$split = preg_split( '/\r?\n/', $raw );
$lines = is_array( $split ) ? $split : array();
$result = array();
foreach ( $lines as $line ) {
$h = strtolower( sanitize_text_field( trim( $line ) ) );
if ( '' === $h ) {
continue;
}
// Preserve wildcard prefix before stripping URL parts.
$prefix = '';
if ( str_starts_with( $h, '*.' ) ) {
$prefix = '*.';
$h = substr( $h, 2 );
}
// Strip scheme (http://, https://, etc.).
$stripped = preg_replace( '/^[a-z][a-z0-9+\-.]*:\/\//', '', $h );
if ( is_string( $stripped ) ) {
$h = $stripped;
}
// Strip path, query string, and fragment — keep only host[:port].
$h = substr( $h, 0, strcspn( $h, '/?#' ) );
// Strip port number.
$no_port = preg_replace( '/:\d+$/', '', $h );
if ( is_string( $no_port ) ) {
$h = $no_port;
}
$h = trim( $h, '. ' );
// Strip www. prefix — example.com already covers www.example.com at match time.
if ( str_starts_with( $h, 'www.' ) ) {
$h = substr( $h, 4 );
}
$h = $prefix . $h;
if ( preg_match( '/^(\*\.)?[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)+$/', $h ) ) {
$result[] = $h;
}
}
$result = array_values( array_unique( $result ) );
sort( $result );
return $result;
}
/**
* Renders the API credentials status block shown at the top of the General tab.
*
* @return void
*/
public function section_general_status(): void {
$gv_ok = $this->is_google_vision_configured();
$te_ok = $this->is_tineye_configured();
$pd_ok = $this->is_picdefense_configured();
?>
<div class="mra-provider-status">
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
<ul>
<li>
<?php if ( $gv_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $te_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $pd_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
PicDefense
</li>
</ul>
</div>
<?php
}
/**
* Renders the description for the "Alert Hostnames" filter section.
*
* @return void
*/
public function section_filters_include(): void {
?>
<p class="description">
<?php esc_html_e( 'Matches from these hostnames are flagged as critical copyright alerts. Entering example.com also covers all its subdomains.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the description for the "Ignored Hostnames" filter section.
*
* @return void
*/
public function section_filters_exclude(): void {
?>
<p class="description">
<?php esc_html_e( 'Matches from these hostnames are silently ignored and not counted as potential copyright issues. Entering example.com also covers all its subdomains. A hostname already present in the alert list cannot be added here.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the "Delete data on uninstall" checkbox field.
*
@ -339,6 +739,68 @@ class Settings {
<?php
}
/**
* Renders the alert hostname filter textarea.
*
* @return void
*/
public function field_filter_include(): void {
$options = (array) get_option( self::OPTION_NAME, array() );
$raw = $options['filter_include'] ?? array();
$strings = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $item ) {
if ( is_string( $item ) ) {
$strings[] = $item;
}
}
}
$value = implode( "\n", $strings );
?>
<textarea
id="filter_include"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[filter_include]"
rows="8"
class="large-text code"
placeholder="example.com"
><?php echo esc_textarea( $value ); ?></textarea>
<p class="description">
<?php esc_html_e( 'One hostname per line. example.com covers all its subdomains. Explicit wildcard: *.example.com.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the ignored hostname filter textarea.
*
* @return void
*/
public function field_filter_exclude(): void {
$options = (array) get_option( self::OPTION_NAME, array() );
$raw = $options['filter_exclude'] ?? array();
$strings = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $item ) {
if ( is_string( $item ) ) {
$strings[] = $item;
}
}
}
$value = implode( "\n", $strings );
?>
<textarea
id="filter_exclude"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[filter_exclude]"
rows="8"
class="large-text code"
placeholder="cdn.example.com"
><?php echo esc_textarea( $value ); ?></textarea>
<p class="description">
<?php esc_html_e( 'One hostname per line. example.com covers all its subdomains. Explicit wildcard: *.example.com.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the external batch size field.
*
@ -436,49 +898,31 @@ class Settings {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$gv_ok = $this->is_google_vision_configured();
$te_ok = $this->is_tineye_configured();
$pd_ok = $this->is_picdefense_configured();
$tab = $this->get_active_tab();
$tabs = $this->get_tabs();
$page_url = admin_url( 'admin.php?page=robotstxt-mediaaudit-settings' );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<div class="mra-provider-status">
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
<ul>
<li>
<?php if ( $gv_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $te_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $pd_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
PicDefense
</li>
</ul>
</div>
<nav class="nav-tab-wrapper">
<?php foreach ( $tabs as $key => $label ) : ?>
<a
href="<?php echo esc_url( add_query_arg( 'tab', $key, $page_url ) ); ?>"
class="nav-tab<?php echo ( $tab === $key ) ? ' nav-tab-active' : ''; ?>"
><?php echo esc_html( $label ); ?></a>
<?php endforeach; ?>
</nav>
<form method="post" action="options.php">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( 'robotstxt-mediaaudit-settings' );
submit_button();
?>
<?php settings_fields( self::OPTION_GROUP ); ?>
<input
type="hidden"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[_tab]"
value="<?php echo esc_attr( $tab ); ?>"
/>
<?php do_settings_sections( 'mra-settings-' . $tab ); ?>
<?php submit_button(); ?>
</form>
</div>
<?php

View file

@ -7,19 +7,22 @@
namespace MediaRightsAudit\Core;
use MediaRightsAudit\Admin\Settings;
/**
* Handles tasks that run once when the plugin is activated.
*/
class Activator {
/**
* Creates database tables and seeds the initial DB version.
* Creates database tables, seeds the initial DB version, and sets default settings.
*
* @return void
*/
public static function activate(): void {
Database::create_tables();
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
Settings::maybe_seed_defaults();
flush_rewrite_rules();
}
}

View file

@ -112,7 +112,7 @@ class Plugin {
'robotstxt-mediaaudit',
array( $this->audit_page, 'render' ),
'dashicons-camera',
60
11
);
$this->audit_page->set_hook_suffix( $suffix );

186
includes/External/HostnameFilter.php vendored Normal file
View file

@ -0,0 +1,186 @@
<?php
/**
* Utility for classifying hostnames against the alert/ignored filter lists.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
/**
* Classifies hostnames against the site's configured alert (include) and
* ignored (exclude) lists stored in the robotstxt_mediaaudit_settings option.
*
* All methods are static; the loaded lists are cached in a class property so
* that the option is read at most once per request.
*/
class HostnameFilter {
/**
* Cached filter lists, populated lazily on first use.
*
* @var array{include: list<string>, exclude: list<string>}|null
*/
private static ?array $lists = null;
/**
* Loads and returns the alert / ignored hostname lists from the plugin option.
*
* Result is cached in self::$lists for the duration of the request.
*
* @return array{include: list<string>, exclude: list<string>}
*/
private static function get_lists(): array {
if ( null !== self::$lists ) {
return self::$lists;
}
$raw = get_option( 'robotstxt_mediaaudit_settings' );
$opts = is_array( $raw ) ? $raw : array();
$raw_include = $opts['filter_include'] ?? array();
$raw_exclude = $opts['filter_exclude'] ?? array();
$include = array();
if ( is_array( $raw_include ) ) {
foreach ( $raw_include as $item ) {
if ( is_string( $item ) && '' !== $item ) {
$include[] = $item;
}
}
}
$exclude = array();
if ( is_array( $raw_exclude ) ) {
foreach ( $raw_exclude as $item ) {
if ( is_string( $item ) && '' !== $item ) {
$exclude[] = $item;
}
}
}
self::$lists = array(
'include' => $include,
'exclude' => $exclude,
);
return self::$lists;
}
/**
* Returns true when $domain matches a filter list entry.
*
* The $entry may carry a `*.` wildcard prefix; its base is derived by
* stripping that prefix. A match occurs when:
* - $domain equals the base exactly, OR
* - $domain ends with a dot followed by the base (subdomain match).
*
* @param string $domain Lowercase hostname to test.
* @param string $entry A list entry, possibly prefixed with `*.`.
*
* @return bool
*/
private static function matches( string $domain, string $entry ): bool {
$base = str_starts_with( $entry, '*.' ) ? substr( $entry, 2 ) : $entry;
if ( $domain === $base ) {
return true;
}
if ( str_ends_with( $domain, '.' . $base ) ) {
return true;
}
return false;
}
/**
* Returns true when the given domain matches any entry in the alert list.
*
* @param string $domain Hostname to classify (will be lower-cased internally).
*
* @return bool
*/
public static function is_alert( string $domain ): bool {
$domain = strtolower( $domain );
$lists = self::get_lists();
foreach ( $lists['include'] as $entry ) {
if ( self::matches( $domain, $entry ) ) {
return true;
}
}
return false;
}
/**
* Returns true when the given domain matches any entry in the ignored list.
*
* @param string $domain Hostname to classify (will be lower-cased internally).
*
* @return bool
*/
public static function is_ignored( string $domain ): bool {
$domain = strtolower( $domain );
$lists = self::get_lists();
foreach ( $lists['exclude'] as $entry ) {
if ( self::matches( $domain, $entry ) ) {
return true;
}
}
return false;
}
/**
* Classifies a domain and returns 'alert', 'ignored', or 'other'.
*
* Alert takes precedence: a domain that is in both lists is classified as
* 'alert'.
*
* @param string $domain Hostname to classify.
*
* @return string 'alert'|'ignored'|'other'
*/
public static function classify( string $domain ): string {
if ( self::is_alert( $domain ) ) {
return 'alert';
}
if ( self::is_ignored( $domain ) ) {
return 'ignored';
}
return 'other';
}
/**
* Returns true when at least one key in $domains is classified as alert.
*
* @param array<string, int> $domains Map of domain => occurrence count.
*
* @return bool
*/
public static function has_alert_domains( array $domains ): bool {
foreach ( array_keys( $domains ) as $domain ) {
if ( self::is_alert( $domain ) ) {
return true;
}
}
return false;
}
/**
* Resets the internal cache so the option is re-read on the next call.
*
* Useful in tests or after the settings option has been updated.
*
* @return void
*/
public static function reset_cache(): void {
self::$lists = null;
}
}