v1.6.0
This commit is contained in:
parent
83d629568a
commit
98b1cf0f3b
17 changed files with 2968 additions and 1313 deletions
|
|
@ -1,5 +1,44 @@
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.6.0 =
|
||||||
|
|
||||||
|
_Release date: 2026-05-05_
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
|
||||||
|
* Settings page reorganised into four native WordPress tabs: General, API Credentials, Filters, External Scanning. Each tab saves independently via a hidden `_tab` sentinel field, preventing cross-tab data loss.
|
||||||
|
* Filters tab: two configurable hostname lists — "Alert Hostnames" (domains that should trigger a copyright-risk alert) and "Ignored Hostnames" (domains to suppress from results). Wildcard notation `*.example.com` matches the apex domain and all subdomains.
|
||||||
|
* Auto-normalization on save: scheme, path, query, fragment, port, and `www.` prefix are stripped; duplicates removed; list sorted alphabetically.
|
||||||
|
* Default filter lists seeded on first activation (idempotent — only runs when the option key does not yet exist): 60 stock-agency / press-agency alert domains, 56 social-media / CDN ignored domains.
|
||||||
|
* `HostnameFilter` utility class (`includes/External/HostnameFilter.php`): static methods `is_alert()`, `is_ignored()`, `classify()` (`alert`|`ignored`|`other`), and `has_alert_domains()`. Domain classification is request-cached.
|
||||||
|
* Dashboard stats strip: new "Alert" card showing the count of attachments that have at least one alert-domain match across all external providers.
|
||||||
|
* Audit list: "Alert" option added to the External Status filter dropdown; when selected, PHP-side filtering classifies each attachment's `top_domains` and returns only those with at least one alert domain.
|
||||||
|
* Audit list: Alert badge in the External Status column for attachments with alert-domain matches; badge label shows the count of non-ignored matching domains.
|
||||||
|
* Quick-view modal: ignored domains are now hidden entirely; alert domains are highlighted with an Alert status badge in the domain table.
|
||||||
|
* Full Report attachment detail page: external results per provider split into three labelled sections — Alert domains, Other domains, and Ignored domains.
|
||||||
|
* Unified CSV export replaces the two previous separate exports:
|
||||||
|
* "Export CSV" — all indexed attachments, one row per attachment, containing: ID, filename, URL, MIME type, file size, internal scan date, external status, has alert (Yes/No), usage count, used-in post titles, Google Vision match count, TinEye match count, PicDefense match count, alert domains, other domains, ignored domains.
|
||||||
|
* "Export CSV (Alerts only)" — same format and columns, filtered to attachments with at least one alert domain match.
|
||||||
|
* "Media Audit" top-level admin menu repositioned to appear immediately below the built-in Media menu (WordPress admin menu position 11).
|
||||||
|
|
||||||
|
**Removed**
|
||||||
|
|
||||||
|
* Previous "Export External Results CSV" bulk action replaced by the unified export above.
|
||||||
|
|
||||||
|
**No schema changes:** `ROBOTSTXT_MEDIAAUDIT_DB_VERSION` remains `1.1.0`.
|
||||||
|
|
||||||
|
**Compatibility**
|
||||||
|
|
||||||
|
* WordPress: 6.8 - 7.0
|
||||||
|
* PHP: 8.2 - 8.4
|
||||||
|
* WP-CLI: 2.x
|
||||||
|
|
||||||
|
**Tests**
|
||||||
|
|
||||||
|
* PHP Coding Standards: WPCS 3.x / PHPCS 3.x
|
||||||
|
* PHPStan: level 9
|
||||||
|
* PHPCompatibility: PHP 8.2 - 8.4
|
||||||
|
|
||||||
= 1.5.0 =
|
= 1.5.0 =
|
||||||
|
|
||||||
_Release date: 2026-05-05_
|
_Release date: 2026-05-05_
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
namespace MediaRightsAudit\Admin;
|
namespace MediaRightsAudit\Admin;
|
||||||
|
|
||||||
|
use MediaRightsAudit\External\HostnameFilter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers and renders the hidden attachment detail admin page.
|
* 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.
|
* 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.
|
* @param int $attachment_id Attachment post ID.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
|
|
@ -241,7 +247,7 @@ class AttachmentDetailPage {
|
||||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
$rows = $wpdb->get_results(
|
$rows = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$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
|
FROM {$wpdb->prefix}mra_external_results
|
||||||
WHERE attachment_id = %d
|
WHERE attachment_id = %d
|
||||||
ORDER BY provider ASC",
|
ORDER BY provider ASC",
|
||||||
|
|
@ -261,6 +267,8 @@ class AttachmentDetailPage {
|
||||||
'picdefense' => 'PicDefense',
|
'picdefense' => 'PicDefense',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$merged_domains = array();
|
||||||
|
|
||||||
foreach ( $rows as $row ) {
|
foreach ( $rows as $row ) {
|
||||||
if ( ! is_array( $row ) ) {
|
if ( ! is_array( $row ) ) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -273,6 +281,9 @@ class AttachmentDetailPage {
|
||||||
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
|
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
|
||||||
$at_val = $row['created_at'] ?? null;
|
$at_val = $row['created_at'] ?? null;
|
||||||
$scanned_at = is_string( $at_val ) ? $at_val : '';
|
$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;
|
$rr_val = $row['raw_response'] ?? null;
|
||||||
$raw = null;
|
$raw = null;
|
||||||
if ( is_string( $rr_val ) && '' !== $rr_val ) {
|
if ( is_string( $rr_val ) && '' !== $rr_val ) {
|
||||||
|
|
@ -280,6 +291,19 @@ class AttachmentDetailPage {
|
||||||
$raw = is_array( $decoded ) ? $decoded : null;
|
$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 '<h3>';
|
||||||
echo esc_html( $name );
|
echo esc_html( $name );
|
||||||
echo ' — ';
|
echo ' — ';
|
||||||
|
|
@ -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>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ namespace MediaRightsAudit\Admin;
|
||||||
|
|
||||||
use MediaRightsAudit\Admin\AttachmentDetailPage;
|
use MediaRightsAudit\Admin\AttachmentDetailPage;
|
||||||
use MediaRightsAudit\External\ExternalScanner;
|
use MediaRightsAudit\External\ExternalScanner;
|
||||||
|
use MediaRightsAudit\External\HostnameFilter;
|
||||||
use MediaRightsAudit\External\ResultsConsolidator;
|
use MediaRightsAudit\External\ResultsConsolidator;
|
||||||
use MediaRightsAudit\Internal\AttachmentIndexer;
|
use MediaRightsAudit\Internal\AttachmentIndexer;
|
||||||
use MediaRightsAudit\Internal\UsageScanner;
|
use MediaRightsAudit\Internal\UsageScanner;
|
||||||
|
|
@ -65,9 +66,9 @@ class AuditPage {
|
||||||
$action = $this->get_current_bulk_action();
|
$action = $this->get_current_bulk_action();
|
||||||
|
|
||||||
if ( 'export_csv' === $action ) {
|
if ( 'export_csv' === $action ) {
|
||||||
$this->handle_export_csv();
|
$this->handle_export_csv( false );
|
||||||
} elseif ( 'export_external_csv' === $action ) {
|
} elseif ( 'export_csv_alerts' === $action ) {
|
||||||
$this->handle_export_external_csv();
|
$this->handle_export_csv( true );
|
||||||
} elseif ( 'run_external_scan' === $action ) {
|
} elseif ( 'run_external_scan' === $action ) {
|
||||||
$this->handle_run_external_scan();
|
$this->handle_run_external_scan();
|
||||||
} elseif ( 'purge_external_data' === $action ) {
|
} elseif ( 'purge_external_data' === $action ) {
|
||||||
|
|
@ -346,22 +347,51 @@ class AuditPage {
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
|
|
||||||
if ( ! empty( $domains ) ) {
|
if ( ! empty( $domains ) ) {
|
||||||
$html .= '<table class="widefat mra-domains-table">';
|
$alert_domains = array();
|
||||||
$html .= '<thead><tr>';
|
$other_domains = array();
|
||||||
$html .= '<th>' . esc_html__( 'Domain', 'robotstxt-mediaaudit' ) . '</th>';
|
|
||||||
$html .= '<th>' . esc_html__( 'Occurrences', 'robotstxt-mediaaudit' ) . '</th>';
|
|
||||||
$html .= '</tr></thead><tbody>';
|
|
||||||
foreach ( $domains as $domain => $count ) {
|
foreach ( $domains as $domain => $count ) {
|
||||||
if ( ! is_string( $domain ) ) {
|
if ( ! is_string( $domain ) ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$html .= sprintf(
|
$classification = HostnameFilter::classify( $domain );
|
||||||
'<tr><td>%s</td><td>%s</td></tr>',
|
if ( 'alert' === $classification ) {
|
||||||
esc_html( $domain ),
|
$alert_domains[ $domain ] = is_numeric( $count ) ? (int) $count : 0;
|
||||||
esc_html( (string) ( 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>';
|
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
|
* @return void
|
||||||
*/
|
*/
|
||||||
private function handle_export_csv(): void {
|
private function handle_export_csv( bool $alerts_only ): void {
|
||||||
check_admin_referer( 'bulk-mra-attachments' );
|
check_admin_referer( 'bulk-mra-attachments' );
|
||||||
|
|
||||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||||
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
|
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$ids = $this->collect_attachment_ids();
|
$ids = $this->collect_attachment_ids();
|
||||||
$rows = $this->build_csv_rows( $ids );
|
$rows = $this->build_unified_csv_rows( $ids, $alerts_only );
|
||||||
|
$suffix = $alerts_only ? '-alerts' : '';
|
||||||
$filename = 'media-audit-' . gmdate( 'Y-m-d' ) . '.csv';
|
$filename = 'media-audit' . $suffix . '-' . gmdate( 'Y-m-d' ) . '.csv';
|
||||||
|
|
||||||
header( 'Content-Type: text/csv; charset=utf-8' );
|
header( 'Content-Type: text/csv; charset=utf-8' );
|
||||||
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
header( 'Content-Disposition: attachment; filename="' . $filename . '"' );
|
||||||
|
|
@ -511,9 +554,17 @@ class AuditPage {
|
||||||
__( 'File URL', 'robotstxt-mediaaudit' ),
|
__( 'File URL', 'robotstxt-mediaaudit' ),
|
||||||
__( 'MIME Type', 'robotstxt-mediaaudit' ),
|
__( 'MIME Type', 'robotstxt-mediaaudit' ),
|
||||||
__( 'File Size (bytes)', 'robotstxt-mediaaudit' ),
|
__( 'File Size (bytes)', 'robotstxt-mediaaudit' ),
|
||||||
|
__( 'Internal Scan Date', 'robotstxt-mediaaudit' ),
|
||||||
__( 'External Status', 'robotstxt-mediaaudit' ),
|
__( 'External Status', 'robotstxt-mediaaudit' ),
|
||||||
|
__( 'Has Alert', 'robotstxt-mediaaudit' ),
|
||||||
__( 'Usage Count', '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
|
* Columns: ID, filename, URL, MIME type, file size, internal scan date,
|
||||||
* appear with empty provider columns.
|
* external status, has alert, usage count, used-in titles, per-provider
|
||||||
|
* match counts, and classified domain lists (alert / other / ignored).
|
||||||
*
|
*
|
||||||
* @return void
|
* 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.
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* Each row represents one attachment × provider combination. Attachments with
|
* @param array<int, int> $ids Attachment IDs to export (empty = all).
|
||||||
* no provider results are included with empty provider columns. If $ids is
|
* @param bool $alerts_only Include only attachments with alert domains.
|
||||||
* empty, all indexed attachments are exported.
|
|
||||||
*
|
|
||||||
* @param array<int, int> $ids Attachment IDs to export (empty = all).
|
|
||||||
*
|
*
|
||||||
* @return array<int, array<int, string>>
|
* @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;
|
global $wpdb;
|
||||||
|
|
||||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||||
|
|
||||||
if ( empty( $ids ) ) {
|
if ( empty( $ids ) ) {
|
||||||
$rows = $wpdb->get_results(
|
$index_rows = $wpdb->get_results(
|
||||||
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
|
"SELECT * FROM {$wpdb->prefix}mra_media_index ORDER BY attachment_id ASC",
|
||||||
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",
|
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||||
$rows = $wpdb->get_results(
|
$index_rows = $wpdb->get_results(
|
||||||
$wpdb->prepare(
|
$wpdb->prepare(
|
||||||
"SELECT i.attachment_id, i.file_name, i.file_url, i.external_status,
|
"SELECT * FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders}) ORDER BY attachment_id ASC",
|
||||||
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",
|
|
||||||
...$ids
|
...$ids
|
||||||
),
|
),
|
||||||
ARRAY_A
|
ARRAY_A
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
if ( ! is_array( $index_rows ) || empty( $index_rows ) ) {
|
||||||
|
|
||||||
if ( ! $rows ) {
|
|
||||||
return array();
|
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 ) {
|
// Fetch usages.
|
||||||
$aid_raw = $r['attachment_id'] ?? '';
|
$usages = MediaListTable::fetch_usages( $page_ids );
|
||||||
$scanned_raw = $r['external_scanned_at'] ?? '';
|
$usage_titles = array();
|
||||||
$domains_raw = $r['top_domains'] ?? '';
|
foreach ( $usages as $u ) {
|
||||||
$mc_raw = $r['match_count'] ?? '';
|
$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
|
// Fetch external results (all providers) for these attachments.
|
||||||
? json_decode( $domains_raw, true )
|
$ext_placeholders = implode( ',', array_fill( 0, count( $page_ids ), '%d' ) );
|
||||||
: array();
|
|
||||||
$domain_parts = array();
|
$ext_rows = $wpdb->get_results(
|
||||||
if ( is_array( $domains_decoded ) ) {
|
$wpdb->prepare(
|
||||||
foreach ( $domains_decoded as $d ) {
|
"SELECT attachment_id, provider, match_count, top_domains
|
||||||
if ( is_string( $d ) ) {
|
FROM {$wpdb->prefix}mra_external_results
|
||||||
$domain_parts[] = $d;
|
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(
|
$known_providers = array( 'google_vision', 'tineye', 'picdefense' );
|
||||||
is_numeric( $aid_raw ) ? (string) (int) $aid_raw : '',
|
$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_name'] ?? null ) ? (string) $r['file_name'] : '',
|
||||||
is_string( $r['file_url'] ?? null ) ? (string) $r['file_url'] : '',
|
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( $r['external_status'] ?? null ) ? (string) $r['external_status'] : '',
|
||||||
is_string( $scanned_raw ) ? $scanned_raw : '',
|
$has_alert ? __( 'Yes', 'robotstxt-mediaaudit' ) : __( 'No', 'robotstxt-mediaaudit' ),
|
||||||
is_string( $r['provider'] ?? null ) ? (string) $r['provider'] : '',
|
(string) count( $usage_titles[ $aid ] ?? array() ),
|
||||||
is_numeric( $mc_raw ) ? (string) (int) $mc_raw : '',
|
implode( '; ', $usage_titles[ $aid ] ?? array() ),
|
||||||
$domains_str,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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;
|
return $output;
|
||||||
|
|
@ -745,87 +848,6 @@ class AuditPage {
|
||||||
return $ids;
|
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.
|
* Determines the active bulk action from the form submission.
|
||||||
*
|
*
|
||||||
|
|
@ -844,7 +866,7 @@ class AuditPage {
|
||||||
/**
|
/**
|
||||||
* Queries and returns dashboard statistics.
|
* 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 {
|
private static function get_dashboard_stats(): array {
|
||||||
global $wpdb;
|
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'" );
|
$total_matches = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'matches'" );
|
||||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||||
|
|
||||||
|
$total_alert = self::get_alert_attachment_count();
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
'total_indexed' => $total_indexed,
|
'total_indexed' => $total_indexed,
|
||||||
'total_scanned' => $total_scanned,
|
'total_scanned' => $total_scanned,
|
||||||
'total_used' => $total_used,
|
'total_used' => $total_used,
|
||||||
'total_unused' => max( 0, $total_indexed - $total_used ),
|
'total_unused' => max( 0, $total_indexed - $total_used ),
|
||||||
'total_matches' => $total_matches,
|
'total_matches' => $total_matches,
|
||||||
|
'total_alert' => $total_alert,
|
||||||
'pending_index' => AttachmentIndexer::get_pending_count(),
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
namespace MediaRightsAudit\Admin;
|
namespace MediaRightsAudit\Admin;
|
||||||
|
|
||||||
use MediaRightsAudit\Admin\AttachmentDetailPage;
|
use MediaRightsAudit\Admin\AttachmentDetailPage;
|
||||||
|
use MediaRightsAudit\External\HostnameFilter;
|
||||||
|
|
||||||
if ( ! class_exists( 'WP_List_Table' ) ) {
|
if ( ! class_exists( 'WP_List_Table' ) ) {
|
||||||
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
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' ),
|
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
|
||||||
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
|
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
|
||||||
'export_csv' => __( 'Export CSV', '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'];
|
$es_val = $item['external_status'];
|
||||||
$status = is_string( $es_val ) ? $es_val : '';
|
$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(
|
$labels = array(
|
||||||
'pending' => __( 'Pending', 'robotstxt-mediaaudit' ),
|
'pending' => __( 'Pending', 'robotstxt-mediaaudit' ),
|
||||||
'queued' => __( 'Queued', 'robotstxt-mediaaudit' ),
|
'queued' => __( 'Queued', 'robotstxt-mediaaudit' ),
|
||||||
|
|
@ -385,6 +429,11 @@ class MediaListTable extends \WP_List_Table {
|
||||||
// External status filter.
|
// External status filter.
|
||||||
echo '<select name="mra_external_status">';
|
echo '<select name="mra_external_status">';
|
||||||
echo '<option value="">' . esc_html__( 'All statuses', 'robotstxt-mediaaudit' ) . '</option>';
|
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 ) {
|
foreach ( self::EXTERNAL_STATUSES as $s ) {
|
||||||
printf(
|
printf(
|
||||||
'<option value="%s"%s>%s</option>',
|
'<option value="%s"%s>%s</option>',
|
||||||
|
|
@ -456,6 +505,13 @@ class MediaListTable extends \WP_List_Table {
|
||||||
$show_unused = ! empty( $_REQUEST['mra_unused'] );
|
$show_unused = ! empty( $_REQUEST['mra_unused'] );
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Recommended
|
// 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 ) ) {
|
if ( ! in_array( $external_status, self::EXTERNAL_STATUSES, true ) ) {
|
||||||
$external_status = '';
|
$external_status = '';
|
||||||
}
|
}
|
||||||
|
|
@ -485,8 +541,6 @@ class MediaListTable extends \WP_List_Table {
|
||||||
|
|
||||||
$where = implode( ' AND ', $where_parts );
|
$where = implode( ' AND ', $where_parts );
|
||||||
|
|
||||||
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
|
||||||
|
|
||||||
if ( 'usage_count' === $orderby ) {
|
if ( 'usage_count' === $orderby ) {
|
||||||
$order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}";
|
$order_sql = "(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) {$order}";
|
||||||
} elseif ( 'external_scanned_at' === $orderby ) {
|
} elseif ( 'external_scanned_at' === $orderby ) {
|
||||||
|
|
@ -496,30 +550,110 @@ class MediaListTable extends \WP_List_Table {
|
||||||
$order_sql = "i.attachment_id {$order}";
|
$order_sql = "i.attachment_id {$order}";
|
||||||
}
|
}
|
||||||
|
|
||||||
$count_sql = "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index i WHERE {$where}";
|
$select_sql = "SELECT i.*,
|
||||||
$items_sql = "SELECT i.*,
|
|
||||||
(SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_usage mu WHERE mu.attachment_id = i.attachment_id) AS usage_count,
|
(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
|
(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
|
FROM {$wpdb->prefix}mra_media_index i";
|
||||||
WHERE {$where}
|
|
||||||
ORDER BY {$order_sql}
|
|
||||||
LIMIT %d OFFSET %d";
|
|
||||||
|
|
||||||
$count_prepared = empty( $prepare_args )
|
$rows = array();
|
||||||
? $count_sql
|
$total = 0;
|
||||||
: $wpdb->prepare( $count_sql, ...$prepare_args );
|
|
||||||
|
|
||||||
$items_prepared = $wpdb->prepare(
|
if ( $filter_alert ) {
|
||||||
$items_sql,
|
// Fetch ALL matching rows (no LIMIT), then PHP-filter by alert domains.
|
||||||
...array_merge( $prepare_args, array( $per_page, $offset ) )
|
$all_sql = $select_sql . " WHERE {$where} ORDER BY {$order_sql}";
|
||||||
);
|
|
||||||
|
|
||||||
$total = (int) $wpdb->get_var( $count_prepared );
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
|
||||||
$rows = $wpdb->get_results( $items_prepared, ARRAY_A );
|
$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();
|
$this->items = array();
|
||||||
} else {
|
} else {
|
||||||
// Bulk-load usages for this page (avoid N+1).
|
// Bulk-load usages for this page (avoid N+1).
|
||||||
|
|
@ -533,6 +667,9 @@ class MediaListTable extends \WP_List_Table {
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ( $rows as &$row ) {
|
foreach ( $rows as &$row ) {
|
||||||
|
if ( ! is_array( $row ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$rid_val = $row['attachment_id'] ?? null;
|
$rid_val = $row['attachment_id'] ?? null;
|
||||||
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
|
$rid = is_numeric( $rid_val ) ? (int) $rid_val : 0;
|
||||||
$row['usages_data'] = $usage_by[ $rid ] ?? array();
|
$row['usages_data'] = $usage_by[ $rid ] ?? array();
|
||||||
|
|
@ -562,6 +699,74 @@ class MediaListTable extends \WP_List_Table {
|
||||||
// Public query helpers
|
// 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.
|
* Fetches usage rows for a set of attachment IDs in one query.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@
|
||||||
namespace MediaRightsAudit\Admin;
|
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 {
|
class Settings {
|
||||||
|
|
||||||
|
|
@ -24,6 +26,198 @@ class Settings {
|
||||||
*/
|
*/
|
||||||
const OPTION_NAME = 'robotstxt_mediaaudit_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.
|
* Registers all settings, sections, and fields via the WordPress Settings API.
|
||||||
*
|
*
|
||||||
|
|
@ -42,33 +236,35 @@ class Settings {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- General tab ---
|
||||||
add_settings_section(
|
add_settings_section(
|
||||||
'mra_general',
|
'mra_general',
|
||||||
__( 'General', 'robotstxt-mediaaudit' ),
|
'',
|
||||||
'__return_false',
|
array( $this, 'section_general_status' ),
|
||||||
'robotstxt-mediaaudit-settings'
|
'mra-settings-general'
|
||||||
);
|
);
|
||||||
|
|
||||||
add_settings_field(
|
add_settings_field(
|
||||||
'delete_on_uninstall',
|
'delete_on_uninstall',
|
||||||
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
|
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_delete_on_uninstall' ),
|
array( $this, 'field_delete_on_uninstall' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-general',
|
||||||
'mra_general'
|
'mra_general'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- API Credentials tab ---
|
||||||
add_settings_section(
|
add_settings_section(
|
||||||
'mra_api_credentials',
|
'mra_api_credentials',
|
||||||
__( 'API Credentials', 'robotstxt-mediaaudit' ),
|
'',
|
||||||
'__return_false',
|
'__return_false',
|
||||||
'robotstxt-mediaaudit-settings'
|
'mra-settings-api'
|
||||||
);
|
);
|
||||||
|
|
||||||
add_settings_field(
|
add_settings_field(
|
||||||
'google_vision_api_key',
|
'google_vision_api_key',
|
||||||
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
|
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_google_vision_api_key' ),
|
array( $this, 'field_google_vision_api_key' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-api',
|
||||||
'mra_api_credentials'
|
'mra_api_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -76,7 +272,7 @@ class Settings {
|
||||||
'tineye_api_key',
|
'tineye_api_key',
|
||||||
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
|
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_tineye_api_key' ),
|
array( $this, 'field_tineye_api_key' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-api',
|
||||||
'mra_api_credentials'
|
'mra_api_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -84,7 +280,7 @@ class Settings {
|
||||||
'picdefense_user_id',
|
'picdefense_user_id',
|
||||||
__( 'PicDefense User ID', 'robotstxt-mediaaudit' ),
|
__( 'PicDefense User ID', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_picdefense_user_id' ),
|
array( $this, 'field_picdefense_user_id' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-api',
|
||||||
'mra_api_credentials'
|
'mra_api_credentials'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -92,22 +288,54 @@ class Settings {
|
||||||
'picdefense_api_key',
|
'picdefense_api_key',
|
||||||
__( 'PicDefense API Key', 'robotstxt-mediaaudit' ),
|
__( 'PicDefense API Key', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_picdefense_api_key' ),
|
array( $this, 'field_picdefense_api_key' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-api',
|
||||||
'mra_api_credentials'
|
'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(
|
add_settings_section(
|
||||||
'mra_external_scanning',
|
'mra_external_scanning',
|
||||||
__( 'External Scanning', 'robotstxt-mediaaudit' ),
|
'',
|
||||||
'__return_false',
|
'__return_false',
|
||||||
'robotstxt-mediaaudit-settings'
|
'mra-settings-external'
|
||||||
);
|
);
|
||||||
|
|
||||||
add_settings_field(
|
add_settings_field(
|
||||||
'external_batch_size',
|
'external_batch_size',
|
||||||
__( 'Batch Size', 'robotstxt-mediaaudit' ),
|
__( 'Batch Size', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_external_batch_size' ),
|
array( $this, 'field_external_batch_size' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-external',
|
||||||
'mra_external_scanning'
|
'mra_external_scanning'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -115,7 +343,7 @@ class Settings {
|
||||||
'rate_limit_per_minute',
|
'rate_limit_per_minute',
|
||||||
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
|
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
|
||||||
array( $this, 'field_rate_limit_per_minute' ),
|
array( $this, 'field_rate_limit_per_minute' ),
|
||||||
'robotstxt-mediaaudit-settings',
|
'mra-settings-external',
|
||||||
'mra_external_scanning'
|
'mra_external_scanning'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -123,42 +351,214 @@ class Settings {
|
||||||
/**
|
/**
|
||||||
* Sanitises the settings array on save.
|
* 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.
|
* @param mixed $input Raw POST input.
|
||||||
*
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function sanitize( $input ): array {
|
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 ) ) {
|
if ( ! is_array( $input ) ) {
|
||||||
return $output;
|
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;
|
if ( 'general' === $tab ) {
|
||||||
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
|
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
|
||||||
|
}
|
||||||
|
|
||||||
$tineye_val = $input['tineye_api_key'] ?? null;
|
if ( 'api' === $tab ) {
|
||||||
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
|
$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;
|
$tineye_val = $input['tineye_api_key'] ?? null;
|
||||||
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
|
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
|
||||||
|
|
||||||
$pd_key_val = $input['picdefense_api_key'] ?? null;
|
$pd_uid_val = $input['picdefense_user_id'] ?? null;
|
||||||
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
|
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
|
||||||
|
|
||||||
$batch_val = $input['external_batch_size'] ?? null;
|
$pd_key_val = $input['picdefense_api_key'] ?? null;
|
||||||
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
|
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
|
||||||
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
|
}
|
||||||
|
|
||||||
$rl_val = $input['rate_limit_per_minute'] ?? null;
|
if ( 'filters' === $tab ) {
|
||||||
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
|
$output['filter_include'] = $this->sanitize_hostname_list( $input['filter_include'] ?? '' );
|
||||||
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
|
$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;
|
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.
|
* Renders the "Delete data on uninstall" checkbox field.
|
||||||
*
|
*
|
||||||
|
|
@ -339,6 +739,68 @@ class Settings {
|
||||||
<?php
|
<?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.
|
* 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' ) );
|
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
$gv_ok = $this->is_google_vision_configured();
|
$tab = $this->get_active_tab();
|
||||||
$te_ok = $this->is_tineye_configured();
|
$tabs = $this->get_tabs();
|
||||||
$pd_ok = $this->is_picdefense_configured();
|
$page_url = admin_url( 'admin.php?page=robotstxt-mediaaudit-settings' );
|
||||||
?>
|
?>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
|
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
|
||||||
|
|
||||||
<div class="mra-provider-status">
|
<nav class="nav-tab-wrapper">
|
||||||
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
|
<?php foreach ( $tabs as $key => $label ) : ?>
|
||||||
<ul>
|
<a
|
||||||
<li>
|
href="<?php echo esc_url( add_query_arg( 'tab', $key, $page_url ) ); ?>"
|
||||||
<?php if ( $gv_ok ) : ?>
|
class="nav-tab<?php echo ( $tab === $key ) ? ' nav-tab-active' : ''; ?>"
|
||||||
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
|
><?php echo esc_html( $label ); ?></a>
|
||||||
<?php else : ?>
|
<?php endforeach; ?>
|
||||||
<span class="dashicons dashicons-warning" style="color:orange;"></span>
|
</nav>
|
||||||
<?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>
|
|
||||||
|
|
||||||
<form method="post" action="options.php">
|
<form method="post" action="options.php">
|
||||||
<?php
|
<?php settings_fields( self::OPTION_GROUP ); ?>
|
||||||
settings_fields( self::OPTION_GROUP );
|
<input
|
||||||
do_settings_sections( 'robotstxt-mediaaudit-settings' );
|
type="hidden"
|
||||||
submit_button();
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
|
|
|
||||||
|
|
@ -7,19 +7,22 @@
|
||||||
|
|
||||||
namespace MediaRightsAudit\Core;
|
namespace MediaRightsAudit\Core;
|
||||||
|
|
||||||
|
use MediaRightsAudit\Admin\Settings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles tasks that run once when the plugin is activated.
|
* Handles tasks that run once when the plugin is activated.
|
||||||
*/
|
*/
|
||||||
class Activator {
|
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
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function activate(): void {
|
public static function activate(): void {
|
||||||
Database::create_tables();
|
Database::create_tables();
|
||||||
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
|
update_option( 'robotstxt_mediaaudit_db_version', ROBOTSTXT_MEDIAAUDIT_DB_VERSION );
|
||||||
|
Settings::maybe_seed_defaults();
|
||||||
flush_rewrite_rules();
|
flush_rewrite_rules();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ class Plugin {
|
||||||
'robotstxt-mediaaudit',
|
'robotstxt-mediaaudit',
|
||||||
array( $this->audit_page, 'render' ),
|
array( $this->audit_page, 'render' ),
|
||||||
'dashicons-camera',
|
'dashicons-camera',
|
||||||
60
|
11
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->audit_page->set_hook_suffix( $suffix );
|
$this->audit_page->set_hook_suffix( $suffix );
|
||||||
|
|
|
||||||
186
includes/External/HostnameFilter.php
vendored
Normal file
186
includes/External/HostnameFilter.php
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
24
readme.txt
24
readme.txt
|
|
@ -5,11 +5,11 @@ Requires at least: 6.8
|
||||||
Tested up to: 7.0
|
Tested up to: 7.0
|
||||||
Requires PHP: 8.2
|
Requires PHP: 8.2
|
||||||
Requires Plugins: action-scheduler
|
Requires Plugins: action-scheduler
|
||||||
Stable tag: 1.5.0
|
Stable tag: 1.6.0
|
||||||
License: GPL-3.0-or-later
|
License: GPL-3.0-or-later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
|
|
||||||
Audit your media library for copyright risks: track internal usage and run reverse image search via Google Vision and TinEye.
|
Audit your media library for copyright risks: track internal usage, run reverse image search via Google Vision, TinEye, and PicDefense, and classify results against configurable alert and ignored hostname lists.
|
||||||
|
|
||||||
== Description ==
|
== Description ==
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ Media Audit helps site owners and editors understand where their media files are
|
||||||
* **Internal usage scan** — identifies every post, page, and custom post type that references each attachment as featured image, inline content, or post meta.
|
* **Internal usage scan** — identifies every post, page, and custom post type that references each attachment as featured image, inline content, or post meta.
|
||||||
* **External reverse image search** — submits images to Google Cloud Vision (Web Detection) and TinEye Commercial API to find matching pages across the web.
|
* **External reverse image search** — submits images to Google Cloud Vision (Web Detection) and TinEye Commercial API to find matching pages across the web.
|
||||||
* **Consensus detection** — surfaces domains independently confirmed by multiple providers, providing stronger copyright-risk signals.
|
* **Consensus detection** — surfaces domains independently confirmed by multiple providers, providing stronger copyright-risk signals.
|
||||||
* **Audit list** — filterable admin page showing each attachment's external scan status and top matching domains.
|
* **Audit list** — filterable admin page showing each attachment's external scan status, alert classification, and top matching domains.
|
||||||
* **WP-CLI support** — run or schedule scans from the command line.
|
* **WP-CLI support** — run or schedule scans from the command line.
|
||||||
* **GDPR compliance** — full WordPress privacy API integration for personal data export and erasure on request.
|
* **GDPR compliance** — full WordPress privacy API integration for personal data export and erasure on request.
|
||||||
* **Async processing** — all scanning runs via Action Scheduler to avoid blocking web requests.
|
* **Async processing** — all scanning runs via Action Scheduler to avoid blocking web requests.
|
||||||
|
|
@ -98,6 +98,17 @@ By default, no. Enable **Settings → Delete data on uninstall** if you want all
|
||||||
|
|
||||||
Only the 3 latest versions. The full changelog is in [changelog.txt](changelog.txt).
|
Only the 3 latest versions. The full changelog is in [changelog.txt](changelog.txt).
|
||||||
|
|
||||||
|
= 1.6.0 =
|
||||||
|
|
||||||
|
* Settings page reorganised into four tabs: General, API Credentials, Filters, External Scanning.
|
||||||
|
* New Filters tab: configurable Alert Hostnames and Ignored Hostnames lists with wildcard support (`*.example.com`), auto-normalization (strips scheme/path/port/www., deduplicates, sorts), and default pre-populated lists (60 alert domains, 56 ignored domains) seeded on first activation.
|
||||||
|
* New "Alert" status filter in the audit list and alert badge in the External Status column.
|
||||||
|
* Dashboard stats: new "Alert" card showing attachments with at least one alert domain match.
|
||||||
|
* Quick-view modal: ignored domains filtered out; alert domains highlighted with a status badge.
|
||||||
|
* Full Report detail page: external results split into three sections — Alert, Other, and Ignored domains.
|
||||||
|
* Unified CSV export replacing separate internal/external exports: "Export CSV" (all) and "Export CSV (Alerts only)", one row per attachment with all internal and external data including per-provider match counts and classified domain lists.
|
||||||
|
* "Media Audit" admin menu repositioned immediately below the built-in Media menu.
|
||||||
|
|
||||||
= 1.5.0 =
|
= 1.5.0 =
|
||||||
|
|
||||||
* Added PicDefense as a third external scan provider: POST-based reverse image search with risk classification (high/medium/low), backlinks with similarity scores, risk flags (face/logo/landmark/stock/EXIF copyright), and label detection.
|
* Added PicDefense as a third external scan provider: POST-based reverse image search with risk classification (high/medium/low), backlinks with similarity scores, risk flags (face/logo/landmark/stock/EXIF copyright), and label detection.
|
||||||
|
|
@ -111,13 +122,6 @@ Only the 3 latest versions. The full changelog is in [changelog.txt](changelog.t
|
||||||
* Fixed `external_batch_size` setting being ignored by Action Scheduler batch processing.
|
* Fixed `external_batch_size` setting being ignored by Action Scheduler batch processing.
|
||||||
* Fixed TinEye domain extraction using the image CDN URL instead of the webpage URL (API field `backlink`, not `url`).
|
* Fixed TinEye domain extraction using the image CDN URL instead of the webpage URL (API field `backlink`, not `url`).
|
||||||
|
|
||||||
= 1.3.0 =
|
|
||||||
|
|
||||||
* Added per-provider scan tracking table (`mra_provider_status`) so each API is tracked independently.
|
|
||||||
* Added "Sync providers" operation: registers newly configured providers for all existing attachments without rescanning already-completed work.
|
|
||||||
* Changed `mra_external_results.provider` from a fixed ENUM to `varchar(100)` for extensibility.
|
|
||||||
* DB schema bumped to 1.1.0 with an automatic migration on update.
|
|
||||||
|
|
||||||
= 1.2.0 =
|
= 1.2.0 =
|
||||||
|
|
||||||
* Added browser-based AJAX scan runner in the Tools page (runs without WP-Cron or Action Scheduler).
|
* Added browser-based AJAX scan runner in the Tools page (runs without WP-Cron or Action Scheduler).
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Plugin Name: Media Audit (by ROBOTSTXT)
|
* Plugin Name: Media Audit (by ROBOTSTXT)
|
||||||
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-mediaaudit
|
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-mediaaudit
|
||||||
* Description: Internal media library usage auditing and external reverse image search to detect potential copyright issues.
|
* Description: Internal media library usage auditing and external reverse image search to detect potential copyright issues.
|
||||||
* Version: 1.5.0
|
* Version: 1.6.0
|
||||||
* Requires at least: 6.8
|
* Requires at least: 6.8
|
||||||
* Tested up to: 7.0
|
* Tested up to: 7.0
|
||||||
* Requires PHP: 8.2
|
* Requires PHP: 8.2
|
||||||
|
|
@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.5.0' );
|
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.6.0' );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.1.0' );
|
define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.1.0' );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ );
|
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
|
|
|
||||||
1
vendor/composer/autoload_classmap.php
vendored
1
vendor/composer/autoload_classmap.php
vendored
|
|
@ -21,6 +21,7 @@ return array(
|
||||||
'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php',
|
'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php',
|
||||||
'MediaRightsAudit\\External\\ExternalScanner' => $baseDir . '/includes/External/ExternalScanner.php',
|
'MediaRightsAudit\\External\\ExternalScanner' => $baseDir . '/includes/External/ExternalScanner.php',
|
||||||
'MediaRightsAudit\\External\\GoogleVisionProvider' => $baseDir . '/includes/External/GoogleVisionProvider.php',
|
'MediaRightsAudit\\External\\GoogleVisionProvider' => $baseDir . '/includes/External/GoogleVisionProvider.php',
|
||||||
|
'MediaRightsAudit\\External\\HostnameFilter' => $baseDir . '/includes/External/HostnameFilter.php',
|
||||||
'MediaRightsAudit\\External\\PicDefenseProvider' => $baseDir . '/includes/External/PicDefenseProvider.php',
|
'MediaRightsAudit\\External\\PicDefenseProvider' => $baseDir . '/includes/External/PicDefenseProvider.php',
|
||||||
'MediaRightsAudit\\External\\ResultsConsolidator' => $baseDir . '/includes/External/ResultsConsolidator.php',
|
'MediaRightsAudit\\External\\ResultsConsolidator' => $baseDir . '/includes/External/ResultsConsolidator.php',
|
||||||
'MediaRightsAudit\\External\\ScanResult' => $baseDir . '/includes/External/ScanResult.php',
|
'MediaRightsAudit\\External\\ScanResult' => $baseDir . '/includes/External/ScanResult.php',
|
||||||
|
|
|
||||||
1
vendor/composer/autoload_static.php
vendored
1
vendor/composer/autoload_static.php
vendored
|
|
@ -36,6 +36,7 @@ class ComposerStaticInit957728ab3efa005f456e5f9df13a19d2
|
||||||
'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php',
|
'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php',
|
||||||
'MediaRightsAudit\\External\\ExternalScanner' => __DIR__ . '/../..' . '/includes/External/ExternalScanner.php',
|
'MediaRightsAudit\\External\\ExternalScanner' => __DIR__ . '/../..' . '/includes/External/ExternalScanner.php',
|
||||||
'MediaRightsAudit\\External\\GoogleVisionProvider' => __DIR__ . '/../..' . '/includes/External/GoogleVisionProvider.php',
|
'MediaRightsAudit\\External\\GoogleVisionProvider' => __DIR__ . '/../..' . '/includes/External/GoogleVisionProvider.php',
|
||||||
|
'MediaRightsAudit\\External\\HostnameFilter' => __DIR__ . '/../..' . '/includes/External/HostnameFilter.php',
|
||||||
'MediaRightsAudit\\External\\PicDefenseProvider' => __DIR__ . '/../..' . '/includes/External/PicDefenseProvider.php',
|
'MediaRightsAudit\\External\\PicDefenseProvider' => __DIR__ . '/../..' . '/includes/External/PicDefenseProvider.php',
|
||||||
'MediaRightsAudit\\External\\ResultsConsolidator' => __DIR__ . '/../..' . '/includes/External/ResultsConsolidator.php',
|
'MediaRightsAudit\\External\\ResultsConsolidator' => __DIR__ . '/../..' . '/includes/External/ResultsConsolidator.php',
|
||||||
'MediaRightsAudit\\External\\ScanResult' => __DIR__ . '/../..' . '/includes/External/ScanResult.php',
|
'MediaRightsAudit\\External\\ScanResult' => __DIR__ . '/../..' . '/includes/External/ScanResult.php',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue