robotstxt-mediaaudit/includes/Internal/UsageScanner.php
2026-06-03 06:31:47 +00:00

490 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Scans where each indexed attachment is used across the site.
*
* @package MediaRightsAudit\Internal
*/
namespace MediaRightsAudit\Internal;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use MediaRightsAudit\Core\Queue\Scheduler;
/**
* Populates mra_media_usage with one row per attachmentpost reference.
*
* Three context types are detected:
* - featured : the post uses this attachment as its featured image (_thumbnail_id).
* - content : the attachment ID appears in post_content (block editor class wp-image-{id}).
* - meta : a custom field value contains the attachment filename.
*
* Scanning is idempotent: existing usage rows are deleted before each attachment's scan.
*
* Extension hooks:
* - mra/internal/scanner/post_types filter to override scanned post types.
* - mra/internal/scanner/meta_keys filter to add private meta keys to the meta scan.
*/
class UsageScanner {
/**
* Action Scheduler hook name for background scanning.
*/
const AS_HOOK = 'mra_internal_scan_batch';
/**
* Scans one batch of indexed-but-unscanned attachments for usage.
*
* @param int $batch_size Maximum number of attachments to scan per call.
*
* @return int Number of attachments scanned.
*/
public static function scan_batch( int $batch_size = 50 ): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, file_url
FROM {$wpdb->prefix}mra_media_index
WHERE internal_scanned_at IS NULL
ORDER BY attachment_id ASC
LIMIT %d",
$batch_size
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery
if ( ! $rows ) {
return 0;
}
foreach ( $rows as $row ) {
self::scan_attachment(
intval( $row['attachment_id'] ),
strval( $row['file_url'] )
);
}
return count( $rows );
}
/**
* Scans a single attachment for usage and marks it as scanned.
*
* Deletes existing mra_media_usage rows first (idempotent).
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Canonical URL of the original file.
*
* @return void
*/
private static function scan_attachment( int $attachment_id, string $file_url ): void {
global $wpdb;
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . 'mra_media_usage',
array( 'attachment_id' => $attachment_id ),
array( '%d' )
);
self::scan_featured( $attachment_id );
self::scan_content( $attachment_id );
self::scan_meta( $attachment_id, $file_url );
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . 'mra_media_index',
array( 'internal_scanned_at' => current_time( 'mysql', true ) ),
array( 'attachment_id' => $attachment_id ),
array( '%s' ),
array( '%d' )
);
}
/**
* Finds posts that use this attachment as a featured image.
*
* @param int $attachment_id Attachment ID to search for.
*
* @return void
*/
private static function scan_featured( int $attachment_id ): void {
global $wpdb;
$raw_types = apply_filters( 'mra/internal/scanner/post_types', get_post_types( array( 'public' => true ) ) ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
$post_types = array();
if ( is_array( $raw_types ) ) {
foreach ( $raw_types as $type ) {
if ( is_string( $type ) ) {
$post_types[] = $type;
}
}
}
if ( empty( $post_types ) ) {
return;
}
$placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.post_id, p.post_type
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key = '_thumbnail_id'
AND pm.meta_value = %d
AND p.post_status NOT IN ('trash','auto-draft')
AND p.post_type IN ({$placeholders})",
array_merge( array( $attachment_id ), $post_types )
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $results ) {
return;
}
foreach ( $results as $row ) {
self::insert_usage(
$attachment_id,
intval( $row['post_id'] ),
strval( $row['post_type'] ),
'featured'
);
}
}
/**
* Finds posts whose post_content references this attachment.
*
* Detects the block-editor class `wp-image-{id}` written by Gutenberg.
*
* @param int $attachment_id Attachment ID to search for.
*
* @return void
*/
private static function scan_content( int $attachment_id ): void {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_type
FROM {$wpdb->posts}
WHERE post_content LIKE %s
AND post_status NOT IN ('trash','auto-draft')
AND post_type NOT IN ('attachment','revision')",
'%wp-image-' . $attachment_id . '%'
),
ARRAY_A
);
if ( ! $results ) {
return;
}
foreach ( $results as $row ) {
self::insert_usage(
$attachment_id,
intval( $row['ID'] ),
strval( $row['post_type'] ),
'content'
);
}
}
/**
* Finds custom field values that reference this attachment by filename.
*
* Scans non-private meta keys by default. Supports basic serialized values (ACF, etc.).
* The mra/internal/scanner/meta_keys filter can add private (underscore-prefixed) keys.
*
* @param int $attachment_id Attachment ID.
* @param string $file_url Canonical URL, used to extract the filename for matching.
*
* @return void
*/
private static function scan_meta( int $attachment_id, string $file_url ): void {
global $wpdb;
$filename = self::url_basename( $file_url );
if ( '' === $filename ) {
return;
}
$like = '%' . $wpdb->esc_like( $filename ) . '%';
// esc_like('_') returns '\' + '_'; appending '%' produces the LIKE pattern '\_%'
// which matches any meta_key beginning with a literal underscore.
$meta_key_like = $wpdb->esc_like( '_' ) . '%';
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.post_id, p.post_type, pm.meta_key, pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key NOT LIKE %s
AND pm.meta_value LIKE %s
AND p.post_status NOT IN ('trash','auto-draft')
AND p.post_type NOT IN ('attachment','revision')",
$meta_key_like,
$like
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$raw_extra_keys = apply_filters( 'mra/internal/scanner/meta_keys', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
$extra_keys = array();
if ( is_array( $raw_extra_keys ) ) {
foreach ( $raw_extra_keys as $key ) {
if ( is_string( $key ) ) {
$extra_keys[] = $key;
}
}
}
if ( $extra_keys ) {
$placeholders = implode( ',', array_fill( 0, count( $extra_keys ), '%s' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$extra_results = $wpdb->get_results(
$wpdb->prepare(
"SELECT pm.post_id, p.post_type, pm.meta_key, pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key IN ({$placeholders})
AND pm.meta_value LIKE %s
AND p.post_status NOT IN ('trash','auto-draft')
AND p.post_type NOT IN ('attachment','revision')",
array_merge( $extra_keys, array( $like ) )
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$results = $results
? array_merge( $results, (array) $extra_results )
: (array) $extra_results;
}
if ( ! $results ) {
return;
}
// Deduplicate by post_id + meta_key.
$seen = array();
foreach ( $results as $row ) {
$post_id = intval( $row['post_id'] );
$meta_key = strval( $row['meta_key'] );
$key = $post_id . '|' . $meta_key;
if ( isset( $seen[ $key ] ) ) {
continue;
}
// Verify match survives unserialisation (handles ACF arrays, etc.).
$meta_value = strval( $row['meta_value'] );
$values = self::flatten_meta_value( $meta_value );
$matched = false;
foreach ( $values as $v ) {
if ( false !== strpos( $v, $filename ) ) {
$matched = true;
break;
}
}
if ( $matched ) {
$seen[ $key ] = true;
self::insert_usage(
$attachment_id,
$post_id,
strval( $row['post_type'] ),
'meta',
$meta_key
);
}
}
}
/**
* Inserts a single usage record.
*
* @param int $attachment_id Attachment ID.
* @param int $post_id Post that references the attachment.
* @param string $post_type Post type.
* @param string $context 'featured', 'content', or 'meta'.
* @param string|null $meta_key Meta key; only populated when context is 'meta'.
*
* @return void
*/
private static function insert_usage(
int $attachment_id,
int $post_id,
string $post_type,
string $context,
?string $meta_key = null
): void {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
$wpdb->insert(
$wpdb->prefix . 'mra_media_usage',
array(
'attachment_id' => $attachment_id,
'post_id' => $post_id,
'post_type' => $post_type,
'context' => $context,
'meta_key' => $meta_key,
'created_at' => current_time( 'mysql', true ),
),
array( '%d', '%d', '%s', '%s', '%s', '%s' )
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.SlowDBQuery.slow_db_query_meta_key
}
// -------------------------------------------------------------------------
// Public helpers (also used in tests)
// -------------------------------------------------------------------------
/**
* Extracts attachment IDs referenced in post content via the wp-image-{id} class.
*
* Pure PHP — no WordPress dependency; safe to call in unit tests.
*
* @param string $content Post content string.
*
* @return array<int> Unique attachment IDs found.
*/
public static function extract_attachment_ids_from_content( string $content ): array {
if ( ! preg_match_all( '/wp-image-(\d+)/', $content, $matches ) ) {
return array();
}
return array_values( array_unique( array_map( 'intval', $matches[1] ) ) );
}
/**
* Flattens a meta value (including serialized arrays) to a list of strings.
*
* Pure PHP — no WordPress dependency; safe to call in unit tests.
*
* @param mixed $value Raw meta value.
*
* @return array<string>
*/
public static function flatten_meta_value( $value ): array {
if ( is_string( $value ) ) {
// All PHP serialized strings (except the literal "N;") have ':' as the second character.
if ( 'N;' === $value || ( strlen( $value ) >= 2 && ':' === $value[1] ) ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize
$unserialized = unserialize( $value, array( 'allowed_classes' => false ) );
if ( false !== $unserialized && is_array( $unserialized ) ) {
return self::flatten_meta_value( $unserialized );
}
}
return array( $value );
}
if ( is_array( $value ) ) {
$flat = array();
foreach ( $value as $item ) {
foreach ( self::flatten_meta_value( $item ) as $s ) {
$flat[] = $s;
}
}
return $flat;
}
return array();
}
/**
* Returns the filename component of a URL.
*
* Pure PHP — no WordPress dependency; safe to call in unit tests.
*
* @param string $url Full URL.
*
* @return string Filename, or empty string on failure.
*/
public static function url_basename( string $url ): string {
$path = parse_url( $url, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
return is_string( $path ) ? basename( $path ) : '';
}
// -------------------------------------------------------------------------
// Scheduling helpers
// -------------------------------------------------------------------------
/**
* Returns the number of indexed attachments not yet usage-scanned.
*
* @return int
*/
public static function get_pending_count(): int {
global $wpdb;
$result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"SELECT COUNT(*)
FROM {$wpdb->prefix}mra_media_index
WHERE internal_scanned_at IS NULL"
);
return intval( $result );
}
/**
* Processes one scheduled batch and re-queues if more attachments remain.
*
* Called by Action Scheduler via the mra_internal_scan_batch hook.
*
* @return void
*/
public static function process_scheduled_batch(): void {
self::scan_batch();
if ( self::get_pending_count() > 0 ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Enqueues a background scan job if one is not already pending.
*
* @return void
*/
public static function schedule(): void {
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Resets usage scan state for all indexed attachments.
*
* Clears internal_scanned_at so every attachment is re-queued for scanning, and
* deletes all mra_media_usage rows. File metadata in mra_media_index is preserved.
*
* @return int Number of attachments reset.
*/
public static function reset_all(): int {
global $wpdb;
// phpcs:disable WordPress.DB.DirectDatabaseQuery
$count = (int) $wpdb->get_var(
"SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE internal_scanned_at IS NOT NULL"
);
$wpdb->query( "UPDATE {$wpdb->prefix}mra_media_index SET internal_scanned_at = NULL" );
$wpdb->query( "DELETE FROM {$wpdb->prefix}mra_media_usage" );
// phpcs:enable WordPress.DB.DirectDatabaseQuery
return $count;
}
}