This commit is contained in:
Javier Casares 2026-06-03 06:24:03 +00:00
commit 6708bbb67f
43 changed files with 8342 additions and 0 deletions

334
includes/External/ExternalScanner.php vendored Normal file
View file

@ -0,0 +1,334 @@
<?php
/**
* External scan batch processor.
*
* @package MediaRightsAudit\External
*/
namespace MediaRightsAudit\External;
use MediaRightsAudit\Core\Queue\Scheduler;
/**
* Processes the external scan queue: runs each pending attachment through
* all active providers and stores results in mra_external_results.
*
* Active providers are resolved at runtime via the mra/external/providers filter.
* Scanning is triggered manually by an administrator (Run External Scan bulk action
* or wp mra scan-external CLI) never automatically, to control API costs.
*
* Extension hook: mra/external/providers filter to register additional providers.
*/
class ExternalScanner {
/**
* Action Scheduler hook name for background external scanning.
*/
const AS_HOOK = 'mra_external_scan_batch';
/**
* Scans one batch of pending attachments with all active providers.
*
* @param int $batch_size Maximum number of attachments to process.
*
* @return int Number of attachments attempted.
*/
public static function scan_batch( int $batch_size = 10 ): int {
global $wpdb;
$providers = self::get_active_providers();
if ( empty( $providers ) ) {
return 0;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT attachment_id, file_url
FROM {$wpdb->prefix}mra_media_index
WHERE external_status = 'queued'
ORDER BY attachment_id ASC
LIMIT %d",
$batch_size
),
ARRAY_A
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
if ( ! $rows ) {
return 0;
}
foreach ( $rows as $row ) {
$aid_val = $row['attachment_id'] ?? null;
$url_val = $row['file_url'] ?? null;
$aid = is_numeric( $aid_val ) ? (int) $aid_val : 0;
$url = is_string( $url_val ) ? $url_val : '';
if ( $aid <= 0 || '' === $url ) {
continue;
}
self::process_attachment( $aid, $url, $providers );
}
return count( $rows );
}
/**
* Returns the count of attachments still waiting for external scanning.
*
* @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 external_status = 'queued'"
);
return is_numeric( $result ) ? (int) $result : 0;
}
/**
* Processes one scheduled batch and re-queues if more remain.
*
* Called by Action Scheduler via the mra_external_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 );
}
}
/**
* Promotes all attachments with external_status = 'pending' to 'queued'.
*
* Does not schedule an Action Scheduler action. Useful in CLI context where
* processing runs synchronously in a loop rather than via background jobs.
*
* @return void
*/
public static function queue_all_pending(): void {
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'queued'
WHERE external_status = 'pending'"
);
}
/**
* Marks all pending attachments as queued and schedules a background scan batch.
*
* Moves every attachment with external_status = 'pending' to 'queued' so the
* status change is visible immediately in the admin list. Only schedules the
* Action Scheduler action if there are now queued items and no batch is already running.
*
* @return void
*/
public static function schedule(): void {
global $wpdb;
$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'queued'
WHERE external_status = 'pending'"
);
if ( self::get_pending_count() > 0 && ! Scheduler::has_pending( self::AS_HOOK ) ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Marks specific attachments as pending and schedules a scan batch.
*
* Idempotent: already-pending attachments are left unchanged;
* previously-scanned or error attachments are reset to pending.
*
* @param array<int, int> $attachment_ids Attachment IDs to queue. Must be non-empty.
*
* @return void
*/
public static function queue_attachments( array $attachment_ids ): void {
if ( empty( $attachment_ids ) ) {
return;
}
global $wpdb;
$ids = array_map( 'intval', $attachment_ids );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'queued', external_scanned_at = NULL
WHERE attachment_id IN ({$placeholders})
AND external_status != 'queued'",
...$ids
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
// Schedule directly — do not call schedule() which would also mark all 'pending' items.
if ( ! Scheduler::has_pending( self::AS_HOOK ) ) {
Scheduler::schedule_single( self::AS_HOOK );
}
}
/**
* Deletes external scan results and resets status to pending for given IDs.
*
* @param array<int, int> $attachment_ids Attachment IDs to purge.
*
* @return void
*/
public static function purge( array $attachment_ids ): void {
if ( empty( $attachment_ids ) ) {
return;
}
global $wpdb;
$ids = array_map( 'intval', $attachment_ids );
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}mra_external_results
WHERE attachment_id IN ({$placeholders})",
...$ids
)
);
$wpdb->query(
$wpdb->prepare(
"UPDATE {$wpdb->prefix}mra_media_index
SET external_status = 'pending', external_scanned_at = NULL
WHERE attachment_id IN ({$placeholders})",
...$ids
)
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
/**
* Scans a single attachment with all providers and updates the index.
*
* @param int $attachment_id WordPress attachment ID.
* @param string $file_url Public URL of the image.
* @param array<AbstractProvider> $providers Resolved provider list.
*
* @return void
*/
private static function process_attachment( int $attachment_id, string $file_url, array $providers ): void {
global $wpdb;
$any_success = false;
$any_match = false;
foreach ( $providers as $provider ) {
try {
$result = $provider->scan( $attachment_id, $file_url );
self::save_result( $attachment_id, $provider->provider_slug(), $result );
$any_success = true;
if ( $result->match_count() > 0 ) {
$any_match = true;
}
} catch ( \RuntimeException $e ) {
if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log(
sprintf(
'[MRA] %s scan failed for attachment %d: %s',
$provider->provider_slug(),
$attachment_id,
$e->getMessage()
)
);
}
}
}
if ( ! $any_success ) {
$new_status = 'error';
} elseif ( $any_match ) {
$new_status = 'matches';
} else {
$new_status = 'scanned';
}
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . 'mra_media_index',
array(
'external_status' => $new_status,
'external_scanned_at' => current_time( 'mysql', true ),
),
array( 'attachment_id' => $attachment_id ),
array( '%s', '%s' ),
array( '%d' )
);
}
/**
* Persists a provider's scan result (upsert via REPLACE INTO).
*
* @param int $attachment_id WordPress attachment ID.
* @param string $provider_slug Provider machine-readable slug.
* @param ScanResult $result Scan result to store.
*
* @return void
*/
private static function save_result( int $attachment_id, string $provider_slug, ScanResult $result ): void {
global $wpdb;
$raw_json = wp_json_encode( $result->raw_response() );
$domains_json = wp_json_encode( $result->top_domains() );
$wpdb->replace( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->prefix . 'mra_external_results',
array(
'attachment_id' => $attachment_id,
'provider' => $provider_slug,
'raw_response' => ( false !== $raw_json ) ? $raw_json : '{}',
'match_count' => $result->match_count(),
'top_domains' => ( false !== $domains_json ) ? $domains_json : '{}',
'created_at' => current_time( 'mysql', true ),
),
array( '%d', '%s', '%s', '%d', '%s', '%s' )
);
}
/**
* Resolves active providers from the mra/external/providers filter.
*
* @return array<AbstractProvider>
*/
private static function get_active_providers(): array {
$raw = apply_filters( 'mra/external/providers', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
$providers = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $p ) {
if ( $p instanceof AbstractProvider ) {
$providers[] = $p;
}
}
}
return $providers;
}
}