robotstxt-mediaaudit/includes/Admin/AttachmentDetailPage.php
2026-06-03 06:28:34 +00:00

595 lines
21 KiB
PHP

<?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>';
}
}
}
}
}