This commit is contained in:
Javier Casares 2026-06-03 06:28:34 +00:00
commit 83d629568a
20 changed files with 1321 additions and 15 deletions

View file

@ -102,6 +102,21 @@
color: #856404;
}
.mra-picrisk-high {
background: #f8d7da;
color: #721c24;
}
.mra-picrisk-medium {
background: #fff3cd;
color: #856404;
}
.mra-picrisk-low {
background: #d4edda;
color: #155724;
}
.mra-badge-match-count {
background: #721c24;
color: #fff;

View file

@ -1,5 +1,57 @@
== Changelog ==
= 1.5.0 =
_Release date: 2026-05-05_
**Added**
* PicDefense as a third external scan provider: POST-based copyright risk analysis via `POST /checkImageRisk`, authenticated with `X-API-TOKEN: {userId}:{apiKey}` header.
* PicDefense results include risk classification (`picrisk`: high/medium/low), backlinks with similarity score and image URL, risk flags (face, logo, landmark, stock, EXIF copyright), and label detection.
* Full Report attachment detail page: PicDefense section with picRisk badge, risk flags list, backlinks table sorted by similarity score (highest first), and detected labels.
* Audit list quick-view modal: picRisk badge shown inline next to PicDefense match count.
* Settings page: PicDefense User ID and API Key fields with credential status indicator.
**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.4.0 =
_Release date: 2026-05-05_
**Added**
* Full attachment detail page accessible via "Full Report" row action in the audit list and "View full report →" link in the quick-view modal. Shows file metadata, internal usage, and complete raw external scan results per provider:
* Google Vision: pages with matching images (URL + page title), full image matches, and partial image matches — all as clickable links.
* TinEye: backlinks table with webpage URL, direct image URL, and crawl date — sorted by crawl date, newest first.
**Fixed**
* `rate_limit_per_minute` setting (Settings page) was stored but never read by providers — both Google Vision and TinEye used a hardcoded limit of 10 req/min regardless of configuration.
* `external_batch_size` setting was ignored by Action Scheduler batch processing and by the browser AJAX runner — both always processed 10 attachments per batch.
* TinEye domain extraction used the wrong API field: `backlink.url` is the direct image URL (CDN), while `backlink.backlink` is the webpage URL. The `top_domains` summary and the detail page now show the correct page domains and URLs.
**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.3.0 =
_Release date: 2026-05-05_

View file

@ -0,0 +1,595 @@
<?php
/**
* Admin page controller for the Attachment Detail (full scan report) view.
*
* @package MediaRightsAudit\Admin
*/
namespace MediaRightsAudit\Admin;
/**
* Registers and renders the hidden attachment detail admin page.
*
* Displays the full scan report for a single attachment, including:
* - File metadata from mra_media_index.
* - Internal usage (post references).
* - Raw external scan results per provider.
*/
class AttachmentDetailPage {
/**
* Returns the URL for the attachment detail page.
*
* @param int $attachment_id WordPress attachment post ID.
*
* @return string
*/
public static function url( int $attachment_id ): string {
return admin_url( 'admin.php?page=robotstxt-mediaaudit-detail&attachment_id=' . $attachment_id );
}
/**
* Renders the full attachment detail page.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only display page.
$raw_id = isset( $_GET['attachment_id'] ) && is_string( $_GET['attachment_id'] )
? (int) sanitize_text_field( wp_unslash( $_GET['attachment_id'] ) )
: 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$attachment_id = $raw_id;
if ( $attachment_id <= 0 ) {
wp_die( esc_html__( 'Invalid attachment ID.', 'robotstxt-mediaaudit' ) );
}
$post = get_post( $attachment_id );
if ( null === $post || 'attachment' !== $post->post_type ) {
wp_die( esc_html__( 'Invalid attachment ID.', 'robotstxt-mediaaudit' ) );
}
$filename = esc_html( '' !== $post->post_title ? $post->post_title : sprintf( '#%d', $attachment_id ) );
$back_url = admin_url( 'admin.php?page=robotstxt-mediaaudit' );
$index_row = $this->get_index_row( $attachment_id );
echo '<div class="wrap">';
echo '<h1><a href="' . esc_url( $back_url ) . '">' . esc_html__( '← Media Audit', 'robotstxt-mediaaudit' ) . '</a> &mdash; ' . $filename . '</h1>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $filename is already escaped via esc_html() above.
// -----------------------------------------------------------------------
// File Information
// -----------------------------------------------------------------------
echo '<h2>' . esc_html__( 'File Information', 'robotstxt-mediaaudit' ) . '</h2>';
echo '<table class="widefat mra-detail-meta"><tbody>';
$fn_raw = isset( $index_row['file_name'] ) && is_string( $index_row['file_name'] ) ? $index_row['file_name'] : '';
$url_raw = isset( $index_row['file_url'] ) && is_string( $index_row['file_url'] ) ? $index_row['file_url'] : '';
$mt_raw = isset( $index_row['mime_type'] ) && is_string( $index_row['mime_type'] ) ? $index_row['mime_type'] : '';
$fs_raw = isset( $index_row['file_size'] ) ? $index_row['file_size'] : null;
$is_raw = isset( $index_row['internal_scanned_at'] ) && is_string( $index_row['internal_scanned_at'] ) ? $index_row['internal_scanned_at'] : '';
$es_raw = isset( $index_row['external_status'] ) && is_string( $index_row['external_status'] ) ? $index_row['external_status'] : '';
printf(
'<tr><th>%s</th><td>%s</td></tr>',
esc_html__( 'ID', 'robotstxt-mediaaudit' ),
esc_html( (string) $attachment_id )
);
printf(
'<tr><th>%s</th><td>%s</td></tr>',
esc_html__( 'Filename', 'robotstxt-mediaaudit' ),
esc_html( $fn_raw )
);
if ( '' !== $url_raw ) {
printf(
'<tr><th>%s</th><td><a href="%s" target="_blank">%s</a></td></tr>',
esc_html__( 'File URL', 'robotstxt-mediaaudit' ),
esc_url( $url_raw ),
esc_html( $url_raw )
);
}
printf(
'<tr><th>%s</th><td>%s</td></tr>',
esc_html__( 'MIME Type', 'robotstxt-mediaaudit' ),
esc_html( $mt_raw )
);
$file_size_str = '';
if ( null !== $fs_raw && is_numeric( $fs_raw ) && (int) $fs_raw > 0 ) {
$file_size_str = size_format( (int) $fs_raw );
}
printf(
'<tr><th>%s</th><td>%s</td></tr>',
esc_html__( 'File size', 'robotstxt-mediaaudit' ),
esc_html( $file_size_str )
);
printf(
'<tr><th>%s</th><td>%s</td></tr>',
esc_html__( 'Internal scan date', 'robotstxt-mediaaudit' ),
esc_html( $is_raw )
);
printf(
'<tr><th>%s</th><td>%s</td></tr>',
esc_html__( 'External Status', 'robotstxt-mediaaudit' ),
esc_html( $es_raw )
);
echo '</tbody></table>';
// Thumbnail.
$thumb = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => 'mra-detail-thumb' ) );
if ( $thumb ) {
echo '<p>' . $thumb . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_get_attachment_image is safe.
}
// -----------------------------------------------------------------------
// Internal Usage
// -----------------------------------------------------------------------
echo '<h2>' . esc_html__( 'Internal Usage', 'robotstxt-mediaaudit' ) . '</h2>';
$usages = MediaListTable::fetch_usages( array( $attachment_id ) );
$context_labels = array(
'featured' => __( 'Featured Image', 'robotstxt-mediaaudit' ),
'content' => __( 'Post Content', 'robotstxt-mediaaudit' ),
'meta' => __( 'Custom Field', 'robotstxt-mediaaudit' ),
);
if ( empty( $usages ) ) {
echo '<p>' . esc_html__( 'Not used in any post.', 'robotstxt-mediaaudit' ) . '</p>';
} else {
echo '<table class="widefat mra-usages-table">';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Post', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Type', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Context', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Status', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $usages as $u ) {
$pid_val = $u['post_id'] ?? null;
$post_id = is_numeric( $pid_val ) ? (int) $pid_val : 0;
$t_raw = $u['post_title'] ?? '';
$post_title = is_string( $t_raw ) && '' !== $t_raw ? $t_raw : sprintf( '#%d', $post_id );
$pt_val = $u['post_type'] ?? null;
$post_type = is_string( $pt_val ) ? $pt_val : '';
$ctx_val = $u['context'] ?? null;
$context = is_string( $ctx_val ) ? $ctx_val : '';
$status_val = $u['post_status'] ?? null;
$status = is_string( $status_val ) ? $status_val : '';
$mk_val = $u['meta_key'] ?? null;
$meta_key = is_string( $mk_val ) ? $mk_val : '';
$ctx_label = isset( $context_labels[ $context ] ) ? $context_labels[ $context ] : esc_html( $context );
if ( '' !== $meta_key ) {
$ctx_label .= ' <code>' . esc_html( $meta_key ) . '</code>';
}
$edit_link = get_edit_post_link( $post_id );
$post_cell = $edit_link
? sprintf( '<a href="%s">%s</a>', esc_url( $edit_link ), esc_html( $post_title ) )
: esc_html( $post_title );
echo '<tr>';
echo '<td>' . $post_cell . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $post_cell already escaped above.
echo '<td>' . esc_html( $post_type ) . '</td>';
echo '<td>' . $ctx_label . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $ctx_label already escaped above.
echo '<td>' . esc_html( $status ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
// -----------------------------------------------------------------------
// External Scan Results
// -----------------------------------------------------------------------
echo '<h2>' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '</h2>';
$this->render_external_results( $attachment_id );
echo '</div>'; // .wrap
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Fetches the mra_media_index row for a given attachment ID.
*
* @param int $attachment_id Attachment post ID.
*
* @return array<array-key, mixed>|null
*/
private function get_index_row( int $attachment_id ): ?array {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT file_url, file_name, mime_type, file_size, internal_scanned_at, external_status, external_scanned_at
FROM {$wpdb->prefix}mra_media_index
WHERE attachment_id = %d
LIMIT 1",
$attachment_id
),
ARRAY_A
);
return is_array( $row ) ? $row : null;
}
/**
* Fetches and renders the external scan results for a given attachment.
*
* @param int $attachment_id Attachment post ID.
*
* @return void
*/
private function render_external_results( int $attachment_id ): void {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT provider, match_count, raw_response, created_at
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id = %d
ORDER BY provider ASC",
$attachment_id
),
ARRAY_A
);
if ( empty( $rows ) ) {
echo '<p>' . esc_html__( 'No external scan data available.', 'robotstxt-mediaaudit' ) . '</p>';
return;
}
$provider_names = array(
'google_vision' => 'Google Cloud Vision',
'tineye' => 'TinEye',
'picdefense' => 'PicDefense',
);
foreach ( $rows as $row ) {
if ( ! is_array( $row ) ) {
continue;
}
$slug_val = $row['provider'] ?? null;
$slug = is_string( $slug_val ) ? $slug_val : '';
$name = isset( $provider_names[ $slug ] ) ? $provider_names[ $slug ] : ucwords( str_replace( '_', ' ', $slug ) );
$mc_val = $row['match_count'] ?? null;
$match_count = is_numeric( $mc_val ) ? (int) $mc_val : 0;
$at_val = $row['created_at'] ?? null;
$scanned_at = is_string( $at_val ) ? $at_val : '';
$rr_val = $row['raw_response'] ?? null;
$raw = null;
if ( is_string( $rr_val ) && '' !== $rr_val ) {
$decoded = json_decode( $rr_val, true );
$raw = is_array( $decoded ) ? $decoded : null;
}
echo '<h3>';
echo esc_html( $name );
echo ' &mdash; ';
echo esc_html(
sprintf(
/* translators: %d: number of external matches */
_n( '%d match', '%d matches', $match_count, 'robotstxt-mediaaudit' ),
$match_count
)
);
if ( '' !== $scanned_at ) {
echo ' &mdash; ' . esc_html(
sprintf(
/* translators: %s: scan date string */
__( 'scanned %s', 'robotstxt-mediaaudit' ),
$scanned_at
)
);
}
echo '</h3>';
if ( null !== $raw ) {
if ( 'google_vision' === $slug ) {
$this->render_google_vision_tables( $raw );
} elseif ( 'tineye' === $slug ) {
$this->render_tineye_table( $raw );
} elseif ( 'picdefense' === $slug ) {
$this->render_picdefense_table( $raw );
}
}
}
}
/**
* Renders the Google Cloud Vision result tables.
*
* @param array<mixed> $raw Decoded raw_response array.
*
* @return void
*/
private function render_google_vision_tables( array $raw ): void {
$responses_val = $raw['responses'] ?? null;
if ( ! is_array( $responses_val ) || empty( $responses_val ) ) {
return;
}
$first = $responses_val[0] ?? null;
if ( ! is_array( $first ) ) {
return;
}
$web_val = $first['webDetection'] ?? null;
$web = is_array( $web_val ) ? $web_val : array();
// Pages with matching images.
$pages_val = $web['pagesWithMatchingImages'] ?? null;
$pages = is_array( $pages_val ) ? $pages_val : array();
if ( ! empty( $pages ) ) {
echo '<table class="widefat mra-detail-table">';
echo '<caption>' . esc_html__( 'Pages with this image', 'robotstxt-mediaaudit' ) . '</caption>';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Page URL', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Page Title', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $pages as $page ) {
if ( ! is_array( $page ) ) {
continue;
}
$page_url_val = $page['url'] ?? null;
$page_url = is_string( $page_url_val ) ? $page_url_val : '';
$page_title_val = $page['pageTitle'] ?? null;
$page_title = is_string( $page_title_val ) ? $page_title_val : '';
echo '<tr>';
echo '<td><a href="' . esc_url( $page_url ) . '" target="_blank">' . esc_html( $page_url ) . '</a></td>';
echo '<td>' . esc_html( $page_title ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
// Full matching images.
$full_val = $web['fullMatchingImages'] ?? null;
$full = is_array( $full_val ) ? $full_val : array();
if ( ! empty( $full ) ) {
echo '<table class="widefat mra-detail-table">';
echo '<caption>' . esc_html__( 'Full image matches', 'robotstxt-mediaaudit' ) . '</caption>';
echo '<thead><tr><th>' . esc_html__( 'Image URL', 'robotstxt-mediaaudit' ) . '</th></tr></thead><tbody>';
foreach ( $full as $img ) {
if ( ! is_array( $img ) ) {
continue;
}
$img_url_val = $img['url'] ?? null;
$img_url = is_string( $img_url_val ) ? $img_url_val : '';
echo '<tr><td><a href="' . esc_url( $img_url ) . '" target="_blank">' . esc_html( $img_url ) . '</a></td></tr>';
}
echo '</tbody></table>';
}
// Partial matching images.
$partial_val = $web['partialMatchingImages'] ?? null;
$partial = is_array( $partial_val ) ? $partial_val : array();
if ( ! empty( $partial ) ) {
echo '<table class="widefat mra-detail-table">';
echo '<caption>' . esc_html__( 'Partial image matches', 'robotstxt-mediaaudit' ) . '</caption>';
echo '<thead><tr><th>' . esc_html__( 'Image URL', 'robotstxt-mediaaudit' ) . '</th></tr></thead><tbody>';
foreach ( $partial as $img ) {
if ( ! is_array( $img ) ) {
continue;
}
$img_url_val = $img['url'] ?? null;
$img_url = is_string( $img_url_val ) ? $img_url_val : '';
echo '<tr><td><a href="' . esc_url( $img_url ) . '" target="_blank">' . esc_html( $img_url ) . '</a></td></tr>';
}
echo '</tbody></table>';
}
}
/**
* Renders the TinEye backlinks table, sorted by crawl_date DESC.
*
* @param array<mixed> $raw Decoded raw_response array.
*
* @return void
*/
private function render_tineye_table( array $raw ): void {
$results_val = $raw['results'] ?? null;
$results = is_array( $results_val ) ? $results_val : array();
$matches_val = $results['matches'] ?? null;
$matches = is_array( $matches_val ) ? $matches_val : array();
// Flatten all backlinks from all matches.
$backlinks = array();
foreach ( $matches as $match ) {
if ( ! is_array( $match ) ) {
continue;
}
$bl_val = $match['backlinks'] ?? null;
$bls = is_array( $bl_val ) ? $bl_val : array();
foreach ( $bls as $bl ) {
if ( is_array( $bl ) ) {
$backlinks[] = $bl;
}
}
}
if ( empty( $backlinks ) ) {
return;
}
// Sort by crawl_date DESC.
usort(
$backlinks,
static function ( array $a, array $b ): int {
$da = isset( $a['crawl_date'] ) && is_string( $a['crawl_date'] ) ? $a['crawl_date'] : '';
$db = isset( $b['crawl_date'] ) && is_string( $b['crawl_date'] ) ? $b['crawl_date'] : '';
return strcmp( $db, $da );
}
);
echo '<table class="widefat mra-detail-table">';
echo '<caption>' . esc_html__( 'Backlinks found by TinEye (sorted by crawl date, newest first)', 'robotstxt-mediaaudit' ) . '</caption>';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Page URL', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Image URL', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Crawl date', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $backlinks as $bl ) {
// 'backlink' = page URL; 'url' = direct image URL.
$page_url_val = $bl['backlink'] ?? null;
$page_url = is_string( $page_url_val ) ? $page_url_val : '';
$img_url_val = $bl['url'] ?? null;
$img_url = is_string( $img_url_val ) ? $img_url_val : '';
$bl_date_val = $bl['crawl_date'] ?? null;
$bl_date = is_string( $bl_date_val ) ? $bl_date_val : '';
echo '<tr>';
echo '<td><a href="' . esc_url( $page_url ) . '" target="_blank">' . esc_html( $page_url ) . '</a></td>';
echo '<td><a href="' . esc_url( $img_url ) . '" target="_blank">' . esc_html( $img_url ) . '</a></td>';
echo '<td>' . esc_html( $bl_date ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
/**
* Renders the PicDefense scan result sections.
*
* @param array<mixed> $raw Decoded raw_response array.
*
* @return void
*/
private function render_picdefense_table( array $raw ): void {
$data_raw = $raw['data'] ?? null;
$data_arr = is_array( $data_raw ) ? $data_raw : array();
$data = isset( $data_arr[0] ) && is_array( $data_arr[0] ) ? $data_arr[0] : array();
if ( empty( $data ) ) {
return;
}
// picRisk badge.
$picrisk_val = $data['picrisk'] ?? null;
$picrisk = is_string( $picrisk_val ) ? strtolower( $picrisk_val ) : '';
if ( '' !== $picrisk ) {
echo '<p><span class="mra-badge mra-picrisk-' . esc_attr( $picrisk ) . '">' . esc_html( ucfirst( $picrisk ) ) . '</span></p>';
}
// Risk flags.
$flags = array();
if ( true === ( $data['face'] ?? null ) ) {
$flags[] = esc_html__( 'Face detected', 'robotstxt-mediaaudit' );
}
if ( true === ( $data['logo'] ?? null ) ) {
$flags[] = esc_html__( 'Logo detected', 'robotstxt-mediaaudit' );
}
if ( true === ( $data['landmark'] ?? null ) ) {
$flags[] = esc_html__( 'Landmark detected', 'robotstxt-mediaaudit' );
}
if ( true === ( $data['stock'] ?? null ) ) {
$flags[] = esc_html__( 'Stock image', 'robotstxt-mediaaudit' );
}
if ( true === ( $data['exif_copyrighted'] ?? null ) ) {
$holder_val = $data['exif_copyrightHolder'] ?? '';
$holder = is_string( $holder_val ) ? $holder_val : '';
$flags[] = sprintf(
/* translators: %s: copyright holder name */
esc_html__( 'EXIF copyright: %s', 'robotstxt-mediaaudit' ),
esc_html( $holder )
);
}
echo '<h4>' . esc_html__( 'Risk flags', 'robotstxt-mediaaudit' ) . '</h4>';
if ( empty( $flags ) ) {
echo '<p>' . esc_html__( 'No risk flags detected.', 'robotstxt-mediaaudit' ) . '</p>';
} else {
echo '<ul>';
foreach ( $flags as $flag ) {
echo '<li>' . $flag . '</li>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already escaped above.
}
echo '</ul>';
}
// Backlinks table.
$backlinks_raw = $data['backlinks'] ?? null;
$backlinks = is_array( $backlinks_raw ) ? $backlinks_raw : array();
// Filter to valid entries only.
$valid_backlinks = array();
foreach ( $backlinks as $bl ) {
if ( is_array( $bl ) ) {
$valid_backlinks[] = $bl;
}
}
if ( ! empty( $valid_backlinks ) ) {
// Sort by similarity_score DESC.
usort(
$valid_backlinks,
static function ( array $a, array $b ): int {
$sa = isset( $a['similarity_score'] ) && is_numeric( $a['similarity_score'] ) ? (float) $a['similarity_score'] : 0.0;
$sb = isset( $b['similarity_score'] ) && is_numeric( $b['similarity_score'] ) ? (float) $b['similarity_score'] : 0.0;
if ( $sb > $sa ) {
return 1;
}
if ( $sb < $sa ) {
return -1;
}
return 0;
}
);
echo '<table class="widefat mra-detail-table">';
echo '<caption>' . esc_html__( 'Backlinks found by PicDefense', 'robotstxt-mediaaudit' ) . '</caption>';
echo '<thead><tr>';
echo '<th>' . esc_html__( 'Page URL', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Image URL', 'robotstxt-mediaaudit' ) . '</th>';
echo '<th>' . esc_html__( 'Similarity', 'robotstxt-mediaaudit' ) . '</th>';
echo '</tr></thead><tbody>';
foreach ( $valid_backlinks as $bl ) {
$page_url_val = $bl['url'] ?? null;
$page_url = is_string( $page_url_val ) ? $page_url_val : '';
$img_url_val = $bl['backlink_image_url'] ?? null;
$img_url = is_string( $img_url_val ) ? $img_url_val : '';
$score_val = $bl['similarity_score'] ?? null;
$score = is_numeric( $score_val ) ? number_format( (float) $score_val, 2 ) : '';
echo '<tr>';
echo '<td><a href="' . esc_url( $page_url ) . '" target="_blank">' . esc_html( $page_url ) . '</a></td>';
echo '<td><a href="' . esc_url( $img_url ) . '" target="_blank">' . esc_html( $img_url ) . '</a></td>';
echo '<td>' . esc_html( $score ) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
}
// Labels.
$labels_val = $data['labels'] ?? null;
if ( is_array( $labels_val ) ) {
$labels_with_score_val = $labels_val['labelsWithScore'] ?? null;
$labels_with_score = is_array( $labels_with_score_val ) ? $labels_with_score_val : array();
if ( ! empty( $labels_with_score ) ) {
$label_str_val = $labels_with_score[0] ?? null;
$label_str = is_string( $label_str_val ) ? $label_str_val : '';
if ( '' !== $label_str ) {
echo '<h4>' . esc_html__( 'Labels', 'robotstxt-mediaaudit' ) . '</h4>';
echo '<p>' . esc_html( $label_str ) . '</p>';
}
}
}
}
}

View file

@ -7,6 +7,7 @@
namespace MediaRightsAudit\Admin;
use MediaRightsAudit\Admin\AttachmentDetailPage;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\External\ResultsConsolidator;
use MediaRightsAudit\Internal\AttachmentIndexer;
@ -269,7 +270,7 @@ class AuditPage {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT provider, match_count, top_domains, created_at
"SELECT provider, match_count, top_domains, raw_response, created_at
FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id = %d
ORDER BY provider ASC",
@ -285,6 +286,7 @@ class AuditPage {
$provider_names = array(
'google_vision' => 'Google Cloud Vision',
'tineye' => 'TinEye',
'picdefense' => 'PicDefense',
);
$html = '<h4 class="mra-section-heading">' . esc_html__( 'External Scan Results', 'robotstxt-mediaaudit' ) . '</h4>';
@ -303,6 +305,12 @@ class AuditPage {
$td_val = $row['top_domains'] ?? null;
$td_raw = is_string( $td_val ) ? json_decode( $td_val, true ) : null;
$domains = is_array( $td_raw ) ? $td_raw : array();
$rr_val = $row['raw_response'] ?? null;
$raw = null;
if ( is_string( $rr_val ) && '' !== $rr_val ) {
$rr_decoded = json_decode( $rr_val, true );
$raw = is_array( $rr_decoded ) ? $rr_decoded : null;
}
$html .= '<div class="mra-provider-result">';
$html .= '<strong>' . esc_html( $name ) . '</strong> — ';
@ -319,6 +327,19 @@ class AuditPage {
$html .= esc_html__( 'No matches', 'robotstxt-mediaaudit' );
}
if ( 'picdefense' === $slug && null !== $raw ) {
$pd_data_raw = $raw['data'] ?? null;
$pd_arr = is_array( $pd_data_raw ) ? $pd_data_raw : array();
$pd_data = isset( $pd_arr[0] ) && is_array( $pd_arr[0] ) ? $pd_arr[0] : null;
if ( is_array( $pd_data ) ) {
$pr_val = $pd_data['picrisk'] ?? null;
$picrisk = is_string( $pr_val ) ? strtolower( $pr_val ) : '';
if ( '' !== $picrisk ) {
$html .= ' <span class="mra-badge mra-picrisk-' . esc_attr( $picrisk ) . '">' . esc_html( ucfirst( $picrisk ) ) . '</span>';
}
}
}
if ( '' !== $scanned_at ) {
$html .= ' <small>' . esc_html( $scanned_at ) . '</small>';
}
@ -364,6 +385,10 @@ class AuditPage {
}
}
$html .= '<p class="mra-detail-link"><a href="' . esc_url( AttachmentDetailPage::url( $attachment_id ) ) . '">'
. esc_html__( 'View full report →', 'robotstxt-mediaaudit' )
. '</a></p>';
return $html;
}

View file

@ -7,6 +7,8 @@
namespace MediaRightsAudit\Admin;
use MediaRightsAudit\Admin\AttachmentDetailPage;
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
@ -172,6 +174,11 @@ class MediaListTable extends \WP_List_Table {
$id,
__( 'View Details', 'robotstxt-mediaaudit' )
),
'full_report' => sprintf(
'<a href="%s">%s</a>',
esc_url( AttachmentDetailPage::url( $id ) ),
__( 'Full Report', 'robotstxt-mediaaudit' )
),
);
return $title . $this->row_actions( $actions );

View file

@ -80,6 +80,22 @@ class Settings {
'mra_api_credentials'
);
add_settings_field(
'picdefense_user_id',
__( 'PicDefense User ID', 'robotstxt-mediaaudit' ),
array( $this, 'field_picdefense_user_id' ),
'robotstxt-mediaaudit-settings',
'mra_api_credentials'
);
add_settings_field(
'picdefense_api_key',
__( 'PicDefense API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_picdefense_api_key' ),
'robotstxt-mediaaudit-settings',
'mra_api_credentials'
);
add_settings_section(
'mra_external_scanning',
__( 'External Scanning', 'robotstxt-mediaaudit' ),
@ -126,6 +142,12 @@ class Settings {
$tineye_val = $input['tineye_api_key'] ?? null;
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
$pd_uid_val = $input['picdefense_user_id'] ?? null;
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
$pd_key_val = $input['picdefense_api_key'] ?? null;
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
$batch_val = $input['external_batch_size'] ?? null;
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
@ -265,6 +287,58 @@ class Settings {
<?php
}
/**
* Renders the PicDefense User ID field.
*
* @return void
*/
public function field_picdefense_user_id(): void {
$options = (array) get_option( self::OPTION_NAME, array() );
$val = $options['picdefense_user_id'] ?? '';
$value = is_string( $val ) ? $val : '';
?>
<input
type="text"
id="picdefense_user_id"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[picdefense_user_id]"
value="<?php echo esc_attr( $value ); ?>"
class="regular-text"
autocomplete="off"
/>
<p class="description">
<?php esc_html_e( 'Required for PicDefense copyright risk analysis. Your PicDefense account user ID.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the PicDefense API key field.
*
* @return void
*/
public function field_picdefense_api_key(): void {
$options = (array) get_option( self::OPTION_NAME, array() );
$val = $options['picdefense_api_key'] ?? '';
$value = is_string( $val ) ? $val : '';
?>
<input
type="password"
id="picdefense_api_key"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[picdefense_api_key]"
value="<?php echo esc_attr( $value ); ?>"
class="regular-text"
autocomplete="off"
/>
<p class="description">
<?php esc_html_e( 'Required for PicDefense copyright risk analysis. Your PicDefense API key.', 'robotstxt-mediaaudit' ); ?>
&mdash;
<a href="https://picdefense.io" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View pricing', 'robotstxt-mediaaudit' ); ?>
</a>
</p>
<?php
}
/**
* Renders the external batch size field.
*
@ -315,6 +389,43 @@ class Settings {
<?php
}
/**
* Returns whether Google Cloud Vision is configured.
*
* @return bool
*/
private function is_google_vision_configured(): bool {
$raw = get_option( self::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$key = $opts['google_vision_api_key'] ?? '';
return is_string( $key ) && '' !== trim( $key );
}
/**
* Returns whether TinEye is configured.
*
* @return bool
*/
private function is_tineye_configured(): bool {
$raw = get_option( self::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$key = $opts['tineye_api_key'] ?? '';
return is_string( $key ) && '' !== trim( $key );
}
/**
* Returns whether PicDefense is configured.
*
* @return bool
*/
private function is_picdefense_configured(): bool {
$raw = get_option( self::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$uid = $opts['picdefense_user_id'] ?? '';
$key = $opts['picdefense_api_key'] ?? '';
return is_string( $uid ) && '' !== trim( $uid ) && is_string( $key ) && '' !== trim( $key );
}
/**
* Renders the Settings admin page.
*
@ -324,9 +435,44 @@ class Settings {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$gv_ok = $this->is_google_vision_configured();
$te_ok = $this->is_tineye_configured();
$pd_ok = $this->is_picdefense_configured();
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<div class="mra-provider-status">
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
<ul>
<li>
<?php if ( $gv_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $te_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $pd_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
PicDefense
</li>
</ul>
</div>
<form method="post" action="options.php">
<?php
settings_fields( self::OPTION_GROUP );

View file

@ -128,7 +128,11 @@ class ToolsPage {
case 'external':
ExternalScanner::queue_all_pending();
ExternalScanner::scan_batch();
$raw_opts = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts_arr = is_array( $raw_opts ) ? $raw_opts : array();
$bs_setting = $opts_arr['external_batch_size'] ?? null;
$ajax_bs = is_numeric( $bs_setting ) ? max( 1, (int) $bs_setting ) : 10;
ExternalScanner::scan_batch( $ajax_bs );
$counts = ExternalScanner::get_status_counts();
$remaining = $counts['queued'] ?? 0;
$done = ( $counts['scanned'] ?? 0 ) + ( $counts['matches'] ?? 0 ) + ( $counts['error'] ?? 0 );

View file

@ -7,6 +7,7 @@
namespace MediaRightsAudit\Core;
use MediaRightsAudit\Admin\AttachmentDetailPage;
use MediaRightsAudit\Admin\AuditPage;
use MediaRightsAudit\Admin\Settings;
use MediaRightsAudit\Admin\ToolsPage;
@ -14,6 +15,7 @@ use MediaRightsAudit\CLI\Command;
use MediaRightsAudit\External\AbstractProvider;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\External\GoogleVisionProvider;
use MediaRightsAudit\External\PicDefenseProvider;
use MediaRightsAudit\External\TinEyeProvider;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
@ -46,13 +48,21 @@ class Plugin {
*/
private ToolsPage $tools_page;
/**
* Attachment detail page controller.
*
* @var AttachmentDetailPage
*/
private AttachmentDetailPage $detail_page;
/**
* Initialises dependencies.
*/
public function __construct() {
$this->settings = new Settings();
$this->audit_page = new AuditPage();
$this->tools_page = new ToolsPage();
$this->settings = new Settings();
$this->audit_page = new AuditPage();
$this->tools_page = new ToolsPage();
$this->detail_page = new AttachmentDetailPage();
}
/**
@ -138,6 +148,15 @@ class Plugin {
'robotstxt-mediaaudit-settings',
array( $this->settings, 'render' )
);
add_submenu_page(
'', // hidden from menu.
__( 'Attachment Report', 'robotstxt-mediaaudit' ),
'',
'edit_posts',
'robotstxt-mediaaudit-detail',
array( $this->detail_page, 'render' )
);
}
/**
@ -172,9 +191,34 @@ class Plugin {
}
$list[] = new GoogleVisionProvider();
$list[] = new TinEyeProvider();
if ( $this->is_provider_configured( 'picdefense' ) ) {
$list[] = new PicDefenseProvider();
}
return $list;
}
/**
* Returns whether a provider has its credentials configured.
*
* Checks provider-specific option keys in the plugin settings.
*
* @param string $slug Provider slug (e.g. 'picdefense').
*
* @return bool
*/
private function is_provider_configured( string $slug ): bool {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
if ( 'picdefense' === $slug ) {
$uid = $opts['picdefense_user_id'] ?? '';
$key = $opts['picdefense_api_key'] ?? '';
return is_string( $uid ) && '' !== trim( $uid ) && is_string( $key ) && '' !== trim( $key );
}
return false;
}
/**
* Registers WP-CLI commands when running in CLI context.
*

View file

@ -37,7 +37,10 @@ abstract class AbstractProvider {
* @return positive-int
*/
public function rate_limit(): int {
return 10;
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts = is_array( $raw ) ? $raw : array();
$val = $opts['rate_limit_per_minute'] ?? null;
return is_numeric( $val ) ? max( 1, (int) $val ) : 10;
}
/**

View file

@ -138,7 +138,12 @@ class ExternalScanner {
* @return void
*/
public static function process_scheduled_batch(): void {
self::scan_batch();
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts = is_array( $raw ) ? $raw : array();
$bs_val = $opts['external_batch_size'] ?? null;
$batch_size = is_numeric( $bs_val ) ? max( 1, (int) $bs_val ) : 10;
self::scan_batch( $batch_size );
if ( self::get_pending_count() > 0 ) {
Scheduler::schedule_single( self::AS_HOOK );

156
includes/External/PicDefenseProvider.php vendored Normal file
View file

@ -0,0 +1,156 @@
<?php
/**
* PicDefense copyright risk analysis provider.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
use MediaRightsAudit\Admin\Settings;
/**
* Submits images to the PicDefense API and normalises the response
* into a ScanResult.
*
* Uses POST-based image URL submission with X-API-TOKEN header authentication.
* Reads credentials (picdefense_user_id and picdefense_api_key) from plugin settings.
* Match count reflects backlink_count from the API response; top domains are
* extracted from the found_at array.
*/
class PicDefenseProvider extends AbstractProvider {
/**
* PicDefense API endpoint.
*/
const ENDPOINT = 'https://app.picdefense.io/apiRouterv2/checkImageRisk';
/**
* Returns the machine-readable provider identifier.
*
* @return string
*/
public function provider_slug(): string {
return 'picdefense';
}
/**
* Returns the human-readable provider display name.
*
* @return string
*/
public function provider_name(): string {
return 'PicDefense';
}
/**
* Submits the image URL to PicDefense and returns a ScanResult.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image.
*
* @return ScanResult
*
* @throws \RuntimeException On configuration, HTTP, or parse errors.
*/
protected function do_scan( int $attachment_id, string $file_url ): ScanResult {
list( $user_id, $api_key ) = $this->get_credentials();
if ( '' === $user_id || '' === $api_key ) {
throw new \RuntimeException( 'PicDefense credentials are not configured.' );
}
$body = wp_json_encode( array( 'url' => $file_url ) );
if ( false === $body ) {
throw new \RuntimeException( 'Failed to encode PicDefense request body.' );
}
$response = wp_remote_post(
self::ENDPOINT,
array(
'headers' => array(
'Content-Type' => 'application/json; charset=utf-8',
'X-API-TOKEN' => $user_id . ':' . $api_key,
),
'body' => $body,
'timeout' => 60,
)
);
if ( is_wp_error( $response ) ) {
throw new \RuntimeException( $response->get_error_message() ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
$http_code = (int) wp_remote_retrieve_response_code( $response );
if ( 200 !== $http_code ) {
throw new \RuntimeException(
sprintf( 'PicDefense API returned HTTP %d.', $http_code ) // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
);
}
$raw_body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $raw_body, true );
if ( ! is_array( $decoded ) ) {
throw new \RuntimeException( 'Failed to parse PicDefense API response.' );
}
return $this->parse_response( $decoded );
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Reads PicDefense credentials from plugin settings.
*
* @return array{string, string} [user_id, api_key] empty strings if not configured.
*/
private function get_credentials(): array {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$uid_val = $opts['picdefense_user_id'] ?? null;
$key_val = $opts['picdefense_api_key'] ?? null;
$user_id = is_string( $uid_val ) ? trim( $uid_val ) : '';
$api_key = is_string( $key_val ) ? trim( $key_val ) : '';
return array( $user_id, $api_key );
}
/**
* Parses a decoded PicDefense API response into a ScanResult.
*
* @param array<mixed, mixed> $decoded json_decode()'d API response.
*
* @return ScanResult
*
* @throws \RuntimeException When the API response indicates failure.
*/
private function parse_response( array $decoded ): ScanResult {
$status_val = $decoded['status'] ?? null;
$status = is_numeric( $status_val ) ? (int) $status_val : 0;
if ( 1 !== $status ) {
throw new \RuntimeException( 'PicDefense API returned non-success status.' );
}
$data_raw = $decoded['data'] ?? null;
$data_arr = is_array( $data_raw ) ? $data_raw : array();
$data = isset( $data_arr[0] ) && is_array( $data_arr[0] ) ? $data_arr[0] : array();
$bc_val = $data['backlink_count'] ?? null;
$match_count = is_numeric( $bc_val ) ? (int) $bc_val : 0;
$found_at_raw = $data['found_at'] ?? null;
$found_at = is_array( $found_at_raw ) ? $found_at_raw : array();
$found_at = array_slice( $found_at, 0, 10 );
$top_domains = array();
foreach ( $found_at as $domain ) {
if ( is_string( $domain ) && '' !== $domain ) {
$top_domains[ $domain ] = 1;
}
}
return new ScanResult( $match_count, $top_domains, $decoded );
}
}

View file

@ -136,9 +136,10 @@ class TinEyeProvider extends AbstractProvider {
if ( ! is_array( $bl ) ) {
continue;
}
$url_val = $bl['url'] ?? null;
if ( is_string( $url_val ) && '' !== $url_val ) {
$backlink_items[] = array( 'url' => $url_val );
// 'backlink' is the page URL; 'url' is the direct image URL.
$page_url_val = $bl['backlink'] ?? null;
if ( is_string( $page_url_val ) && '' !== $page_url_val ) {
$backlink_items[] = array( 'url' => $page_url_val );
}
}
}

Binary file not shown.

View file

@ -2,7 +2,7 @@
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.2.0\n"
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.5.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-"
"mediaaudit\n"
"POT-Creation-Date: 2026-05-02T07:36:54+00:00\n"
@ -985,6 +985,124 @@ msgstr "Sincronitza el registre de proveïdors"
msgid "Creates scan entries for any newly registered external providers. Run this after adding a new provider via the mra/external/providers filter. Already-scanned attachments are not affected."
msgstr "Crea entrades d'escaneig per als proveïdors externs registrats recentment. Executa-ho després d'afegir un nou proveïdor mitjançant el filtre mra/external/providers. Els fitxers ja escanejats no es veuen afectats."
#: includes/Admin/AttachmentDetailPage.php
msgid "Full Report"
msgstr "Informe complet"
#: includes/Admin/AttachmentDetailPage.php
msgid "View full report →"
msgstr "Veure l'informe complet →"
#: includes/Core/Plugin.php
msgid "Attachment Report"
msgstr "Informe del fitxer adjunt"
#: includes/Admin/AttachmentDetailPage.php
msgid "File Information"
msgstr "Informació del fitxer"
#: includes/Admin/AttachmentDetailPage.php
msgid "Pages with this image"
msgstr "Pàgines amb aquesta imatge"
#: includes/Admin/AttachmentDetailPage.php
msgid "Full image matches"
msgstr "Coincidències exactes d'imatge"
#: includes/Admin/AttachmentDetailPage.php
msgid "Partial image matches"
msgstr "Coincidències parcials d'imatge"
#: includes/Admin/AttachmentDetailPage.php
msgid "Backlinks found by TinEye (sorted by crawl date, newest first)"
msgstr "Backlinks trobats per TinEye (ordenats per data de rastreig, el més recent primer)"
#: includes/Admin/AttachmentDetailPage.php
msgid "Page URL"
msgstr "URL de la pàgina"
#: includes/Admin/AttachmentDetailPage.php
msgid "Crawl date"
msgstr "Data de rastreig"
#: includes/Admin/AttachmentDetailPage.php
msgid "Page Title"
msgstr "Títol de la pàgina"
#: includes/Admin/AttachmentDetailPage.php
msgid "Image URL"
msgstr "URL de la imatge"
#: includes/Admin/AttachmentDetailPage.php
msgid "No external scan data available."
msgstr "No hi ha dades d'exploració externa disponibles."
#: includes/Admin/AttachmentDetailPage.php
msgid "File size"
msgstr "Mida del fitxer"
#: includes/Admin/AttachmentDetailPage.php
msgid "← Media Audit"
msgstr "← Auditoria de mitjans"
#: includes/Admin/AttachmentDetailPage.php
msgid "Internal scan date"
msgstr "Data de l'exploració interna"
#: includes/Admin/AttachmentDetailPage.php
#, php-format
msgid "scanned %s"
msgstr "explorat el %s"
#: includes/Admin/Settings.php
msgid "PicDefense User ID"
msgstr "ID d'usuari PicDefense"
#: includes/Admin/Settings.php
msgid "PicDefense API Key"
msgstr "Clau API de PicDefense"
#: includes/Admin/AttachmentDetailPage.php
msgid "Face detected"
msgstr "Cara detectada"
#: includes/Admin/AttachmentDetailPage.php
msgid "Logo detected"
msgstr "Logotip detectat"
#: includes/Admin/AttachmentDetailPage.php
msgid "Landmark detected"
msgstr "Monument detectat"
#: includes/Admin/AttachmentDetailPage.php
msgid "Stock image"
msgstr "Imatge de banc"
#: includes/Admin/AttachmentDetailPage.php
#, php-format
msgid "EXIF copyright: %s"
msgstr "Copyright EXIF: %s"
#: includes/Admin/AttachmentDetailPage.php
msgid "Similarity"
msgstr "Similitud"
#: includes/Admin/AttachmentDetailPage.php
msgid "Risk flags"
msgstr "Indicadors de risc"
#: includes/Admin/AttachmentDetailPage.php
msgid "Labels"
msgstr "Etiquetes"
#: includes/Admin/AttachmentDetailPage.php
msgid "Backlinks found by PicDefense"
msgstr "Backlinks trobats per PicDefense"
#: includes/Admin/AttachmentDetailPage.php
msgid "No risk flags detected."
msgstr "No s'han detectat indicadors de risc."
#~ msgid "https://robotstxt.es/plugins/media-audit/"
#~ msgstr "https://robotstxt.es/plugins/media-audit/"

View file

@ -2,7 +2,7 @@
# This file is distributed under the GPL-3.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.2.0\n"
"Project-Id-Version: Media Audit (by ROBOTSTXT) 1.5.0\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/robotstxt-"
"mediaaudit\n"
"POT-Creation-Date: 2026-05-02T07:36:54+00:00\n"
@ -981,6 +981,124 @@ msgstr "Sincronizar registro de proveedores"
msgid "Creates scan entries for any newly registered external providers. Run this after adding a new provider via the mra/external/providers filter. Already-scanned attachments are not affected."
msgstr "Crea entradas de escaneo para los proveedores externos recién registrados. Ejecútalo después de añadir un nuevo proveedor mediante el filtro mra/external/providers. Los archivos ya escaneados no se ven afectados."
#: includes/Admin/AttachmentDetailPage.php
msgid "Full Report"
msgstr "Informe completo"
#: includes/Admin/AttachmentDetailPage.php
msgid "View full report →"
msgstr "Ver informe completo →"
#: includes/Core/Plugin.php
msgid "Attachment Report"
msgstr "Informe del archivo adjunto"
#: includes/Admin/AttachmentDetailPage.php
msgid "File Information"
msgstr "Información del archivo"
#: includes/Admin/AttachmentDetailPage.php
msgid "Pages with this image"
msgstr "Páginas con esta imagen"
#: includes/Admin/AttachmentDetailPage.php
msgid "Full image matches"
msgstr "Coincidencias exactas de imagen"
#: includes/Admin/AttachmentDetailPage.php
msgid "Partial image matches"
msgstr "Coincidencias parciales de imagen"
#: includes/Admin/AttachmentDetailPage.php
msgid "Backlinks found by TinEye (sorted by crawl date, newest first)"
msgstr "Backlinks encontrados por TinEye (ordenados por fecha de rastreo, más reciente primero)"
#: includes/Admin/AttachmentDetailPage.php
msgid "Page URL"
msgstr "URL de la página"
#: includes/Admin/AttachmentDetailPage.php
msgid "Crawl date"
msgstr "Fecha de rastreo"
#: includes/Admin/AttachmentDetailPage.php
msgid "Page Title"
msgstr "Título de la página"
#: includes/Admin/AttachmentDetailPage.php
msgid "Image URL"
msgstr "URL de la imagen"
#: includes/Admin/AttachmentDetailPage.php
msgid "No external scan data available."
msgstr "No hay datos de escaneo externo disponibles."
#: includes/Admin/AttachmentDetailPage.php
msgid "File size"
msgstr "Tamaño del archivo"
#: includes/Admin/AttachmentDetailPage.php
msgid "← Media Audit"
msgstr "← Auditoría de medios"
#: includes/Admin/AttachmentDetailPage.php
msgid "Internal scan date"
msgstr "Fecha del escaneo interno"
#: includes/Admin/AttachmentDetailPage.php
#, php-format
msgid "scanned %s"
msgstr "escaneado el %s"
#: includes/Admin/Settings.php
msgid "PicDefense User ID"
msgstr "ID de usuario PicDefense"
#: includes/Admin/Settings.php
msgid "PicDefense API Key"
msgstr "Clave API de PicDefense"
#: includes/Admin/AttachmentDetailPage.php
msgid "Face detected"
msgstr "Cara detectada"
#: includes/Admin/AttachmentDetailPage.php
msgid "Logo detected"
msgstr "Logo detectado"
#: includes/Admin/AttachmentDetailPage.php
msgid "Landmark detected"
msgstr "Monumento detectado"
#: includes/Admin/AttachmentDetailPage.php
msgid "Stock image"
msgstr "Imagen de banco"
#: includes/Admin/AttachmentDetailPage.php
#, php-format
msgid "EXIF copyright: %s"
msgstr "Copyright EXIF: %s"
#: includes/Admin/AttachmentDetailPage.php
msgid "Similarity"
msgstr "Similitud"
#: includes/Admin/AttachmentDetailPage.php
msgid "Risk flags"
msgstr "Indicadores de riesgo"
#: includes/Admin/AttachmentDetailPage.php
msgid "Labels"
msgstr "Etiquetas"
#: includes/Admin/AttachmentDetailPage.php
msgid "Backlinks found by PicDefense"
msgstr "Backlinks encontrados por PicDefense"
#: includes/Admin/AttachmentDetailPage.php
msgid "No risk flags detected."
msgstr "No se detectaron indicadores de riesgo."
#~ msgid "https://robotstxt.es/plugins/media-audit/"
#~ msgstr "https://robotstxt.es/plugins/media-audit/"

View file

@ -5,7 +5,7 @@ Requires at least: 6.8
Tested up to: 7.0
Requires PHP: 8.2
Requires Plugins: action-scheduler
Stable tag: 1.3.0
Stable tag: 1.5.0
License: GPL-3.0-or-later
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
@ -98,6 +98,19 @@ 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).
= 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.
* Full Report page now shows PicDefense results with picRisk badge, risk flags list, backlinks table sorted by similarity score, and detected labels.
* Audit list modal shows picRisk badge inline next to the PicDefense match count.
= 1.4.0 =
* Added full attachment detail page (Media Audit → Full Report) showing all raw external scan data: Google Vision pages/full/partial matches with clickable URLs; TinEye backlinks with page URL, image URL, and crawl date sorted newest-first.
* Fixed `rate_limit_per_minute` setting having no effect — providers were ignoring it and using a hardcoded value of 10.
* 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`).
= 1.3.0 =
* Added per-provider scan tracking table (`mra_provider_status`) so each API is tracked independently.

View file

@ -3,7 +3,7 @@
* Plugin Name: Media Audit (by ROBOTSTXT)
* 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.
* Version: 1.3.0
* Version: 1.5.0
* Requires at least: 6.8
* Tested up to: 7.0
* Requires PHP: 8.2
@ -23,7 +23,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.3.0' );
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.5.0' );
define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.1.0' );
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ );
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View file

@ -7,6 +7,7 @@ $baseDir = dirname($vendorDir);
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'MediaRightsAudit\\Admin\\AttachmentDetailPage' => $baseDir . '/includes/Admin/AttachmentDetailPage.php',
'MediaRightsAudit\\Admin\\AuditPage' => $baseDir . '/includes/Admin/AuditPage.php',
'MediaRightsAudit\\Admin\\MediaListTable' => $baseDir . '/includes/Admin/MediaListTable.php',
'MediaRightsAudit\\Admin\\Settings' => $baseDir . '/includes/Admin/Settings.php',
@ -20,6 +21,7 @@ return array(
'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php',
'MediaRightsAudit\\External\\ExternalScanner' => $baseDir . '/includes/External/ExternalScanner.php',
'MediaRightsAudit\\External\\GoogleVisionProvider' => $baseDir . '/includes/External/GoogleVisionProvider.php',
'MediaRightsAudit\\External\\PicDefenseProvider' => $baseDir . '/includes/External/PicDefenseProvider.php',
'MediaRightsAudit\\External\\ResultsConsolidator' => $baseDir . '/includes/External/ResultsConsolidator.php',
'MediaRightsAudit\\External\\ScanResult' => $baseDir . '/includes/External/ScanResult.php',
'MediaRightsAudit\\External\\TinEyeProvider' => $baseDir . '/includes/External/TinEyeProvider.php',

View file

@ -22,6 +22,7 @@ class ComposerStaticInit957728ab3efa005f456e5f9df13a19d2
public static $classMap = array (
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'MediaRightsAudit\\Admin\\AttachmentDetailPage' => __DIR__ . '/../..' . '/includes/Admin/AttachmentDetailPage.php',
'MediaRightsAudit\\Admin\\AuditPage' => __DIR__ . '/../..' . '/includes/Admin/AuditPage.php',
'MediaRightsAudit\\Admin\\MediaListTable' => __DIR__ . '/../..' . '/includes/Admin/MediaListTable.php',
'MediaRightsAudit\\Admin\\Settings' => __DIR__ . '/../..' . '/includes/Admin/Settings.php',
@ -35,6 +36,7 @@ class ComposerStaticInit957728ab3efa005f456e5f9df13a19d2
'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php',
'MediaRightsAudit\\External\\ExternalScanner' => __DIR__ . '/../..' . '/includes/External/ExternalScanner.php',
'MediaRightsAudit\\External\\GoogleVisionProvider' => __DIR__ . '/../..' . '/includes/External/GoogleVisionProvider.php',
'MediaRightsAudit\\External\\PicDefenseProvider' => __DIR__ . '/../..' . '/includes/External/PicDefenseProvider.php',
'MediaRightsAudit\\External\\ResultsConsolidator' => __DIR__ . '/../..' . '/includes/External/ResultsConsolidator.php',
'MediaRightsAudit\\External\\ScanResult' => __DIR__ . '/../..' . '/includes/External/ScanResult.php',
'MediaRightsAudit\\External\\TinEyeProvider' => __DIR__ . '/../..' . '/includes/External/TinEyeProvider.php',