prefix resolves to per-site tables * while $wpdb->base_prefix always resolves to the network-wide prefix. */ class NetworkDatabase { /** * Returns the fully-qualified network mirror table name. * * Uses base_prefix so it lives in the main site's DB regardless of active blog. * * @return string */ public static function table_name(): string { global $wpdb; return $wpdb->base_prefix . 'mra_network_index'; } /** * Creates the network mirror table using dbDelta (idempotent). * * @return void */ public static function create_network_table(): void { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $table = self::table_name(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( "CREATE TABLE {$table} ( site_id bigint(20) unsigned NOT NULL, attachment_id bigint(20) unsigned NOT NULL, site_url varchar(255) NOT NULL DEFAULT '', file_name varchar(255) NOT NULL DEFAULT '', file_url text NOT NULL, mime_type varchar(100) NOT NULL DEFAULT '', file_size bigint(20) unsigned NOT NULL DEFAULT 0, external_status enum('pending','queued','scanned','matches','error') NOT NULL DEFAULT 'pending', internal_scanned_at datetime DEFAULT NULL, external_scanned_at datetime DEFAULT NULL, has_alert tinyint(1) NOT NULL DEFAULT 0, is_dismissed tinyint(1) NOT NULL DEFAULT 0, updated_at datetime NOT NULL, PRIMARY KEY (site_id, attachment_id), KEY external_status (external_status), KEY has_alert (has_alert) ) {$charset_collate};" ); } /** * Drops the network mirror table. Used only during full network uninstall. * * @return void */ public static function drop_network_table(): void { global $wpdb; $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" ); } /** * Removes all mirror rows for a given site. * * Called when a site resets all its plugin data (truncate_all). * * @param int $site_id WordPress blog ID. * * @return void */ public static function clear_site( int $site_id ): void { global $wpdb; $wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery self::table_name(), array( 'site_id' => $site_id ), array( '%d' ) ); } /** * Syncs the mirror table for the currently active site. * * Reads mra_media_index, mra_external_results (for has_alert), and * mra_dismissed_alerts (for is_dismissed) from the current site's tables, * then replaces all mirror rows for this site. * * Must be called while switch_to_blog($site_id) is active. * * @param int $site_id WordPress blog ID (must match the currently active blog). * * @return void */ public static function sync_site( int $site_id ): void { global $wpdb; $site_url = get_site_url( $site_id ); $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( "SELECT attachment_id, file_name, file_url, mime_type, file_size, external_status, internal_scanned_at, external_scanned_at FROM {$wpdb->prefix}mra_media_index", ARRAY_A ); self::clear_site( $site_id ); if ( empty( $rows ) || ! is_array( $rows ) ) { return; } $ids = array_map( 'intval', array_column( $rows, 'attachment_id' ) ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // Load top_domains per attachment to compute has_alert. // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $domain_rows = $wpdb->get_results( $wpdb->prepare( "SELECT attachment_id, top_domains FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$placeholders})", ...$ids ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare // Build merged domain map: attachment_id → domain → count. $domains_by_id = array(); if ( is_array( $domain_rows ) ) { foreach ( $domain_rows as $dr ) { if ( ! is_array( $dr ) ) { continue; } $aid = is_numeric( $dr['attachment_id'] ?? null ) ? (int) $dr['attachment_id'] : 0; $td = is_string( $dr['top_domains'] ?? null ) ? json_decode( $dr['top_domains'], true ) : null; if ( $aid <= 0 || ! is_array( $td ) ) { continue; } if ( ! isset( $domains_by_id[ $aid ] ) ) { $domains_by_id[ $aid ] = array(); } foreach ( $td as $domain => $count ) { if ( is_string( $domain ) ) { $domains_by_id[ $aid ][ $domain ] = ( $domains_by_id[ $aid ][ $domain ] ?? 0 ) + ( is_numeric( $count ) ? (int) $count : 0 ); } } } } // Load dismissed attachment IDs. // phpcs:ignore WordPress.DB.DirectDatabaseQuery $dismissed_col = $wpdb->get_col( "SELECT attachment_id FROM {$wpdb->prefix}mra_dismissed_alerts" ); $dismissed_ids = array(); if ( is_array( $dismissed_col ) ) { foreach ( $dismissed_col as $did ) { if ( is_numeric( $did ) ) { $dismissed_ids[ (int) $did ] = true; } } } $now = current_time( 'mysql', true ); foreach ( $rows as $row ) { if ( ! is_array( $row ) ) { continue; } $aid = is_numeric( $row['attachment_id'] ?? null ) ? (int) $row['attachment_id'] : 0; if ( $aid <= 0 ) { continue; } $has_alert = HostnameFilter::has_alert_domains( $domains_by_id[ $aid ] ?? array() ) ? 1 : 0; $is_dismissed = isset( $dismissed_ids[ $aid ] ) ? 1 : 0; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "REPLACE INTO `{$table}` (site_id, attachment_id, site_url, file_name, file_url, mime_type, file_size, external_status, internal_scanned_at, external_scanned_at, has_alert, is_dismissed, updated_at) VALUES (%d, %d, %s, %s, %s, %s, %d, %s, %s, %s, %d, %d, %s)", $site_id, $aid, $site_url, is_string( $row['file_name'] ?? null ) ? $row['file_name'] : '', is_string( $row['file_url'] ?? null ) ? $row['file_url'] : '', is_string( $row['mime_type'] ?? null ) ? $row['mime_type'] : '', is_numeric( $row['file_size'] ?? null ) ? (int) $row['file_size'] : 0, is_string( $row['external_status'] ?? null ) ? $row['external_status'] : 'pending', is_string( $row['internal_scanned_at'] ?? null ) && '' !== $row['internal_scanned_at'] ? $row['internal_scanned_at'] : null, is_string( $row['external_scanned_at'] ?? null ) && '' !== $row['external_scanned_at'] ? $row['external_scanned_at'] : null, $has_alert, $is_dismissed, $now ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared } } /** * Updates the dismissal flag for a single attachment in the mirror. * * Lighter alternative to sync_site() when only dismissal status changes. * * @param int $site_id WordPress blog ID. * @param int $attachment_id WordPress attachment ID. * @param bool $is_dismissed Whether the alert is dismissed. * * @return void */ public static function update_dismissal_flag( int $site_id, int $attachment_id, bool $is_dismissed ): void { global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery self::table_name(), array( 'is_dismissed' => $is_dismissed ? 1 : 0, 'updated_at' => current_time( 'mysql', true ), ), array( 'site_id' => $site_id, 'attachment_id' => $attachment_id, ), array( '%d', '%s' ), array( '%d', '%d' ) ); } }