provider_slug() ] = $provider; } // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $rows = $wpdb->get_results( $wpdb->prepare( "SELECT DISTINCT ps.attachment_id, mi.file_url FROM {$wpdb->prefix}mra_provider_status ps JOIN {$wpdb->prefix}mra_media_index mi ON mi.attachment_id = ps.attachment_id WHERE ps.status = 'queued' ORDER BY ps.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; } // Get queued provider slugs for this attachment. // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $queued_slugs = $wpdb->get_col( $wpdb->prepare( "SELECT provider FROM {$wpdb->prefix}mra_provider_status WHERE attachment_id = %d AND status = 'queued'", $aid ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( empty( $queued_slugs ) ) { continue; } // Intersect queued slugs with currently active providers. $batch_providers = array(); foreach ( $queued_slugs as $slug ) { if ( is_string( $slug ) && isset( $provider_map[ $slug ] ) ) { $batch_providers[] = $provider_map[ $slug ]; } } if ( empty( $batch_providers ) ) { continue; } self::process_attachment( $aid, $url, $batch_providers ); } return count( $rows ); } /** * Returns the count of provider-attachment rows 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_provider_status WHERE 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 { $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 ); delete_transient( 'mra_alert_count' ); if ( self::get_pending_count() > 0 ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Marks all pending provider rows as queued and schedules a background scan batch. * * Calls queue_provider_attachments() for each active provider to populate * mra_provider_status, then syncs the aggregate status on mra_media_index. * Only schedules the Action Scheduler action if there are queued items and * no batch is already running. * * @return void */ public static function schedule(): void { $providers = self::get_active_providers(); foreach ( $providers as $provider ) { self::queue_provider_attachments( $provider->provider_slug() ); } self::sync_aggregate_queued_status(); if ( self::get_pending_count() > 0 && ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Promotes all pending provider rows to queued for all active providers. * * 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 { $providers = self::get_active_providers(); foreach ( $providers as $provider ) { self::queue_provider_attachments( $provider->provider_slug() ); } self::sync_aggregate_queued_status(); } /** * Creates scan entries for newly registered providers. * * For each active provider, inserts mra_provider_status rows for attachments * that have no existing row for that provider. Already-scanned or error rows * are not touched. If new rows are created, syncs aggregate status and * schedules a background scan batch. * * @return int Total number of new rows inserted. */ public static function sync_providers(): int { global $wpdb; $providers = self::get_active_providers(); $total = 0; foreach ( $providers as $provider ) { $slug = $provider->provider_slug(); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status) SELECT mi.attachment_id, %s, 'queued' FROM {$wpdb->prefix}mra_media_index mi LEFT JOIN {$wpdb->prefix}mra_provider_status ps ON ps.attachment_id = mi.attachment_id AND ps.provider = %s WHERE ps.attachment_id IS NULL", $slug, $slug ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $total += (int) $wpdb->rows_affected; } if ( $total > 0 ) { self::sync_aggregate_queued_status(); if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } return $total; } /** * Marks specific attachments as queued for all active providers and schedules a scan batch. * * Idempotent: already-queued or scanned rows are left unchanged; * error rows are reset to queued. * * @param array $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' ) ); $providers = self::get_active_providers(); foreach ( $providers as $provider ) { $slug = $provider->provider_slug(); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status) SELECT attachment_id, %s, 'queued' FROM {$wpdb->prefix}mra_media_index WHERE attachment_id IN ({$placeholders}) ON DUPLICATE KEY UPDATE status = IF(status NOT IN ('scanned','queued'),'queued',status)", array_merge( array( $slug ), $ids ) ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare } self::sync_aggregate_queued_status(); if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Re-queues all provider rows that previously failed with status = 'error'. * * Does not touch pending, queued, or scanned rows. * Schedules a background scan batch if any items were re-queued and * no batch is already pending. * * @return int Number of provider rows re-queued. */ public static function requeue_errors(): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_provider_status WHERE status = 'error'" ); if ( $count > 0 ) { $wpdb->query( "UPDATE {$wpdb->prefix}mra_provider_status SET status = 'queued', scanned_at = NULL WHERE status = 'error'" ); self::sync_aggregate_queued_status(); if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } // phpcs:enable WordPress.DB.DirectDatabaseQuery return $count; } /** * Deletes external scan results and resets status to pending for given IDs. * * @param array $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( "DELETE FROM {$wpdb->prefix}mra_provider_status 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 delete_transient( 'mra_alert_count' ); } /** * Deletes all external scan results and resets every attachment's external_status to pending. * * @return int Number of result rows deleted from mra_external_results. */ public static function purge_all(): int { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery $count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}mra_external_results" ); $wpdb->query( "DELETE FROM {$wpdb->prefix}mra_external_results" ); $wpdb->query( "DELETE FROM {$wpdb->prefix}mra_provider_status" ); $wpdb->query( "UPDATE {$wpdb->prefix}mra_media_index SET external_status = 'pending', external_scanned_at = NULL" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery delete_transient( 'mra_alert_count' ); return $count; } /** * Returns a count of attachments grouped by external_status. * * Reads from the mra_media_index aggregate column for display purposes. * * @return array */ public static function get_status_counts(): array { global $wpdb; $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT external_status, COUNT(*) AS cnt FROM {$wpdb->prefix}mra_media_index GROUP BY external_status", ARRAY_A ); $counts = array( 'pending' => 0, 'queued' => 0, 'scanned' => 0, 'matches' => 0, 'error' => 0, ); if ( is_array( $rows ) ) { foreach ( $rows as $row ) { $s = is_array( $row ) && is_string( $row['external_status'] ) ? $row['external_status'] : ''; if ( isset( $counts[ $s ] ) ) { $cnt = isset( $row['cnt'] ) && is_numeric( $row['cnt'] ) ? (int) $row['cnt'] : 0; $counts[ $s ] = $cnt; } } } return $counts; } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Scans a single attachment with specified providers and updates per-provider status. * * After scanning all providers, calls update_aggregate_status() to sync * the mra_media_index aggregate column. * * @param int $attachment_id WordPress attachment ID. * @param string $file_url Public URL of the image. * @param array $providers Resolved provider list for this attachment. * * @return void */ private static function process_attachment( int $attachment_id, string $file_url, array $providers ): void { global $wpdb; foreach ( $providers as $provider ) { $slug = $provider->provider_slug(); try { $result = $provider->scan( $attachment_id, $file_url ); self::save_result( $attachment_id, $slug, $result ); $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "UPDATE {$wpdb->prefix}mra_provider_status SET status = 'scanned', scanned_at = NOW() WHERE attachment_id = %d AND provider = %s", $attachment_id, $slug ) ); } catch ( \RuntimeException $e ) { $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "UPDATE {$wpdb->prefix}mra_provider_status SET status = 'error', scanned_at = NOW() WHERE attachment_id = %d AND provider = %s", $attachment_id, $slug ) ); 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', $slug, $attachment_id, $e->getMessage() ) ); } } } self::update_aggregate_status( $attachment_id ); } /** * Recomputes and persists the aggregate external_status for a single attachment. * * Logic: * - Any provider still queued → 'queued' * - No provider succeeded → 'error' * - At least one match found → 'matches' * - Otherwise → 'scanned' * * @param int $attachment_id WordPress attachment ID. * * @return void */ private static function update_aggregate_status( int $attachment_id ): void { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(status = 'queued') AS queued_count, SUM(status = 'scanned') AS scanned_count FROM {$wpdb->prefix}mra_provider_status WHERE attachment_id = %d", $attachment_id ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! is_array( $row ) ) { return; } $queued_count = isset( $row['queued_count'] ) && is_numeric( $row['queued_count'] ) ? (int) $row['queued_count'] : 0; $scanned_count = isset( $row['scanned_count'] ) && is_numeric( $row['scanned_count'] ) ? (int) $row['scanned_count'] : 0; if ( $queued_count > 0 ) { $new_status = 'queued'; } elseif ( 0 === $scanned_count ) { $new_status = 'error'; } else { // Check for matches across scanned providers. // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $match_sum = $wpdb->get_var( $wpdb->prepare( "SELECT SUM(match_count) FROM {$wpdb->prefix}mra_external_results WHERE attachment_id = %d", $attachment_id ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $new_status = ( is_numeric( $match_sum ) && (int) $match_sum > 0 ) ? 'matches' : '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' ) ); } /** * Updates mra_media_index.external_status to 'queued' for all attachments * that have at least one queued row in mra_provider_status. * * Only updates rows not already marked 'queued', to avoid unnecessary writes. * * @return void */ private static function sync_aggregate_queued_status(): void { global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "UPDATE {$wpdb->prefix}mra_media_index mi INNER JOIN ( SELECT DISTINCT attachment_id FROM {$wpdb->prefix}mra_provider_status WHERE status = 'queued' ) AS q ON q.attachment_id = mi.attachment_id SET mi.external_status = 'queued' WHERE mi.external_status != 'queued'" ); } /** * Inserts or updates mra_provider_status rows for all indexed attachments. * * For attachments without an existing row for this provider, inserts 'queued'. * For attachments with an existing non-scanned, non-queued row, resets to 'queued'. * Scanned and already-queued rows are left unchanged. * * @param string $provider_slug Provider machine-readable slug. * * @return void */ private static function queue_provider_attachments( string $provider_slug ): void { global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->prefix}mra_provider_status (attachment_id, provider, status) SELECT attachment_id, %s, 'queued' FROM {$wpdb->prefix}mra_media_index ON DUPLICATE KEY UPDATE status = IF(status NOT IN ('scanned','queued'),'queued',status)", $provider_slug ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared } /** * 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 */ 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; } }