This commit is contained in:
Javier Casares 2026-06-08 18:01:39 +00:00
commit b6a55a7f04
27 changed files with 2221 additions and 45 deletions

View file

@ -0,0 +1,261 @@
<?php
/**
* Network-level database operations for the cross-site mirror index.
*
* @package MediaRightsAudit\Core
*/
namespace MediaRightsAudit\Core;
use MediaRightsAudit\External\HostnameFilter;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Manages {base_prefix}mra_network_index, a denormalized mirror of each site's
* mra_media_index that enables fast cross-site queries in the network admin.
*
* Detailed data (top_domains JSON, raw_response) stays in per-site tables and
* is loaded on demand via switch_to_blog(). This table holds only the fields
* needed for list display, filtering, and aggregated stats.
*
* All sync methods must be called while the target site's context is active
* (switch_to_blog already called), so $wpdb->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' )
);
}
}