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

@ -137,6 +137,10 @@ class AlertsPage {
delete_transient( 'mra_alert_count' );
self::invalidate_cache();
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
\MediaRightsAudit\Core\NetworkDatabase::update_dismissal_flag( get_current_blog_id(), $attachment_id, true );
}
}
/**
@ -158,6 +162,10 @@ class AlertsPage {
delete_transient( 'mra_alert_count' );
self::invalidate_cache();
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
\MediaRightsAudit\Core\NetworkDatabase::update_dismissal_flag( get_current_blog_id(), $attachment_id, false );
}
}
// -------------------------------------------------------------------------

View file

@ -222,6 +222,40 @@ class Settings {
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'general';
}
/**
* Returns the effective settings for the current context.
*
* In Multisite central mode, reads from the shared network site option so that
* all providers and scanners pick up network-wide API credentials and filter
* lists without changing per-site DB records.
*
* @return array<string, mixed>
*/
public static function get_effective_settings(): array {
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
$raw = get_site_option( 'robotstxt_mediaaudit_network_settings', array() );
} else {
$raw = get_option( self::OPTION_NAME, array() );
}
return is_array( $raw ) ? $raw : array();
}
/**
* Sanitizes a settings array for the network admin, merging against $current.
*
* Mirrors the tab-based logic of sanitize() but accepts the existing values
* as a parameter rather than reading from get_option(), so it can be used
* for both per-site and network site options.
*
* @param array<string, mixed> $input Raw POST input (must include '_tab').
* @param array<string, mixed> $current Existing option values to merge against.
*
* @return array<string, mixed>
*/
public function sanitize_for_network( array $input, array $current ): array {
return $this->sanitize_with_base( $input, $current );
}
/**
* Registers all settings, sections, and fields via the WordPress Settings API.
*
@ -367,7 +401,19 @@ class Settings {
public function sanitize( $input ): array {
$raw = get_option( self::OPTION_NAME, array() );
$current = is_array( $raw ) ? $raw : array();
$output = $current;
return $this->sanitize_with_base( $input, $current );
}
/**
* Core tab-based sanitization logic shared by sanitize() and sanitize_for_network().
*
* @param mixed $input Raw POST input.
* @param array<string, mixed> $current Existing values to merge against.
*
* @return array<string, mixed>
*/
private function sanitize_with_base( $input, array $current ): array {
$output = $current;
if ( ! is_array( $input ) ) {
return $output;
@ -430,11 +476,18 @@ class Settings {
}
/**
* Sanitises a newline-separated list of hostnames into a deduplicated array.
* Public proxy for sanitize_hostname_list(), used by Network\Settings.
*
* Accepts plain hostnames (example.com) and explicit wildcard prefixes
* (*.example.com). A plain hostname implicitly covers all its subdomains
* at match time.
* @param mixed $raw Raw textarea value.
*
* @return list<string>
*/
public function sanitize_hostname_list_public( mixed $raw ): array {
return $this->sanitize_hostname_list( $raw );
}
/**
* Sanitises a newline-separated hostname list into a deduplicated sorted array.
*
* @param mixed $raw Raw textarea value.
*

View file

@ -11,6 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use MediaRightsAudit\Admin\Settings;
use MediaRightsAudit\Core\Database;
use MediaRightsAudit\Core\Queue\Scheduler;
use MediaRightsAudit\External\ExternalScanner;
@ -132,8 +133,7 @@ class ToolsPage {
case 'external':
ExternalScanner::queue_all_pending();
$raw_opts = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts_arr = is_array( $raw_opts ) ? $raw_opts : array();
$opts_arr = Settings::get_effective_settings();
$bs_setting = $opts_arr['external_batch_size'] ?? null;
$ajax_bs = is_numeric( $bs_setting ) ? max( 1, (int) $bs_setting ) : 10;
ExternalScanner::scan_batch( $ajax_bs );

View file

@ -11,6 +11,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use MediaRightsAudit\Core\NetworkDatabase;
/**
* Manages all custom table creation and schema migrations.
*
@ -29,6 +31,7 @@ class Database {
self::migration_101();
self::migration_110();
self::migration_120();
self::migration_130();
}
/**
@ -57,6 +60,10 @@ class Database {
if ( version_compare( $current, '1.2.0', '<' ) ) {
self::migration_120();
}
if ( version_compare( $current, '1.3.0', '<' ) ) {
self::migration_130();
}
}
/**
@ -92,6 +99,10 @@ class Database {
// phpcs:enable WordPress.DB.DirectDatabaseQuery
delete_transient( 'mra_alert_count' );
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
NetworkDatabase::clear_site( get_current_blog_id() );
}
}
// -------------------------------------------------------------------------
@ -256,4 +267,23 @@ class Database {
) {$charset_collate};"
);
}
/**
* Migration 1.3.0 creates the network mirror table (Multisite central mode only).
*
* For single-site installs this is a no-op. The network table lives in the
* main site's DB and is managed by NetworkDatabase::create_network_table().
* This migration entry exists only to advance the per-site DB version constant.
*
* @return void
*/
private static function migration_130(): void {
if ( ! is_multisite() ) {
return;
}
if ( 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
NetworkDatabase::create_network_table();
}
}
}

View file

@ -0,0 +1,114 @@
<?php
/**
* Handles network-wide activation and deactivation in WordPress Multisite.
*
* @package MediaRightsAudit\Core
*/
namespace MediaRightsAudit\Core;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Manages network-level activation, deactivation, and new-site provisioning.
*
* On network activation, iterates all active sites and runs Activator::activate()
* in each site's context. On new site creation, provisions tables automatically
* when the plugin is network-active.
*/
class NetworkActivator {
/**
* Runs on network-wide plugin activation.
*
* Creates the shared network mirror table, seeds the network mode option
* (default: per_site), and runs per-site activation for every active site.
*
* @return void
*/
public static function network_activate(): void {
if ( ! is_multisite() ) {
return;
}
NetworkDatabase::create_network_table();
if ( false === get_site_option( 'robotstxt_mediaaudit_network_mode' ) ) {
add_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' );
}
foreach ( self::get_active_site_ids() as $site_id ) {
switch_to_blog( $site_id );
Activator::activate();
restore_current_blog();
}
}
/**
* Runs on network-wide plugin deactivation.
*
* Cancels all scheduled Action Scheduler jobs on every site. Tables and
* data are preserved (deactivation is reversible).
*
* @return void
*/
public static function network_deactivate(): void {
if ( ! is_multisite() ) {
return;
}
foreach ( self::get_active_site_ids() as $site_id ) {
switch_to_blog( $site_id );
Deactivator::deactivate();
restore_current_blog();
}
}
/**
* Provisions plugin tables for a newly created site when the plugin is network-active.
*
* Hooked to wp_initialize_site (WordPress 5.1+).
*
* @param \WP_Site $new_site The newly created site object.
*
* @return void
*/
public static function on_new_site( \WP_Site $new_site ): void {
if ( ! is_multisite() ) {
return;
}
if ( ! is_plugin_active_for_network( plugin_basename( ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE ) ) ) {
return;
}
switch_to_blog( (int) $new_site->blog_id );
Activator::activate();
restore_current_blog();
}
/**
* Returns an array of active site IDs on this network.
*
* @return array<int>
*/
public static function get_active_site_ids(): array {
if ( ! is_multisite() ) {
return array( get_current_blog_id() );
}
$sites = get_sites(
array(
'fields' => 'ids',
'number' => 0,
'archived' => 0,
'deleted' => 0,
'spam' => 0,
)
);
return is_array( $sites ) ? array_map( 'intval', $sites ) : array();
}
}

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' )
);
}
}

View file

@ -118,6 +118,20 @@ class Plugin {
* @return void
*/
public function register_admin_pages(): void {
// In central mode, replace the full admin UI with a placeholder notice.
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
add_menu_page(
__( 'Media Audit', 'robotstxt-mediaaudit' ),
__( 'Media Audit', 'robotstxt-mediaaudit' ),
'edit_others_posts',
'robotstxt-mediaaudit',
array( $this, 'render_central_mode_notice' ),
'dashicons-camera',
11
);
return;
}
$suffix = add_menu_page(
__( 'Media Audit', 'robotstxt-mediaaudit' ),
__( 'Media Audit', 'robotstxt-mediaaudit' ),
@ -233,8 +247,7 @@ class Plugin {
* @return bool
*/
private function is_provider_configured( string $slug ): bool {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$opts = Settings::get_effective_settings();
if ( 'picdefense' === $slug ) {
$uid = $opts['picdefense_user_id'] ?? '';
@ -245,6 +258,33 @@ class Plugin {
return false;
}
/**
* Renders the central-mode placeholder shown on each site's admin pages.
*
* Replaces the full per-site admin UI when the network is operating in
* central mode, directing users to the network admin panel instead.
*
* @return void
*/
public function render_central_mode_notice(): void {
$network_url = network_admin_url( 'admin.php?page=robotstxt-mediaaudit-network' );
?>
<div class="wrap">
<h1><?php esc_html_e( 'Media Audit', 'robotstxt-mediaaudit' ); ?></h1>
<div class="notice notice-info" style="margin-top:1em;">
<p>
<?php esc_html_e( 'This plugin is managed at the network level. Media data for this site is collected and stored here, but viewed and managed from the Network Admin panel.', 'robotstxt-mediaaudit' ); ?>
</p>
<p>
<a href="<?php echo esc_url( $network_url ); ?>" class="button button-primary">
<?php esc_html_e( 'Go to Network Media Audit →', 'robotstxt-mediaaudit' ); ?>
</a>
</p>
</div>
</div>
<?php
}
/**
* Registers WP-CLI commands when running in CLI context.
*

View file

@ -11,6 +11,8 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use MediaRightsAudit\Admin\Settings;
/**
* Provides rate-limited scanning via a transient-based per-minute counter.
*
@ -41,8 +43,7 @@ abstract class AbstractProvider {
* @return positive-int
*/
public function rate_limit(): int {
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts = is_array( $raw ) ? $raw : array();
$opts = Settings::get_effective_settings();
$val = $opts['rate_limit_per_minute'] ?? null;
return is_numeric( $val ) ? max( 1, (int) $val ) : 10;
}

View file

@ -11,6 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use MediaRightsAudit\Admin\Settings;
use MediaRightsAudit\Core\Queue\Scheduler;
/**
@ -142,8 +143,7 @@ class ExternalScanner {
* @return void
*/
public static function process_scheduled_batch(): void {
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
$opts = is_array( $raw ) ? $raw : array();
$opts = Settings::get_effective_settings();
$bs_val = $opts['external_batch_size'] ?? null;
$batch_size = is_numeric( $bs_val ) ? max( 1, (int) $bs_val ) : 10;
@ -151,6 +151,10 @@ class ExternalScanner {
delete_transient( 'mra_alert_count' );
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
\MediaRightsAudit\Core\NetworkDatabase::sync_site( get_current_blog_id() );
}
if ( self::get_pending_count() > 0 ) {
Scheduler::schedule_single( self::AS_HOOK );
}

View file

@ -126,9 +126,9 @@ class GoogleVisionProvider extends AbstractProvider {
* @return string Empty string if not configured.
*/
private function get_api_key(): string {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$val = $opts['google_vision_api_key'] ?? null;
$opts = Settings::get_effective_settings();
$val = $opts['google_vision_api_key'] ?? null;
return is_string( $val ) ? trim( $val ) : '';
}

View file

@ -112,8 +112,8 @@ class PicDefenseProvider extends AbstractProvider {
* @return array{string, string} [user_id, api_key] empty strings if not configured.
*/
private function get_credentials(): array {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$opts = Settings::get_effective_settings();
$uid_val = $opts['picdefense_user_id'] ?? null;
$key_val = $opts['picdefense_api_key'] ?? null;
$user_id = is_string( $uid_val ) ? trim( $uid_val ) : '';

View file

@ -104,9 +104,9 @@ class TinEyeProvider extends AbstractProvider {
* @return string Empty string if not configured.
*/
private function get_api_key(): string {
$raw = get_option( Settings::OPTION_NAME, array() );
$opts = is_array( $raw ) ? $raw : array();
$val = $opts['tineye_api_key'] ?? null;
$opts = Settings::get_effective_settings();
$val = $opts['tineye_api_key'] ?? null;
return is_string( $val ) ? trim( $val ) : '';
}

View file

@ -159,6 +159,10 @@ class AttachmentIndexer {
public static function process_scheduled_batch(): void {
self::index_batch();
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
\MediaRightsAudit\Core\NetworkDatabase::sync_site( get_current_blog_id() );
}
if ( self::get_pending_count() > 0 ) {
Scheduler::schedule_single( self::AS_HOOK );
} else {

View file

@ -450,6 +450,10 @@ class UsageScanner {
public static function process_scheduled_batch(): void {
self::scan_batch();
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
\MediaRightsAudit\Core\NetworkDatabase::sync_site( get_current_blog_id() );
}
if ( self::get_pending_count() > 0 ) {
Scheduler::schedule_single( self::AS_HOOK );
}

View file

@ -0,0 +1,73 @@
<?php
/**
* Network admin aggregated Alerts page.
*
* @package MediaRightsAudit\Network
*/
namespace MediaRightsAudit\Network;
use MediaRightsAudit\Core\NetworkDatabase;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Renders the aggregated alerts view for the network admin.
*
* Shows all active (non-dismissed) alert attachments across all sites,
* ordered by site. Alert dismissal is performed at the per-site level via
* a link to the site's own Alerts page.
*/
class AlertsPage {
/**
* Renders the network Alerts page.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$table = new NetworkListTable( true );
$table->prepare_items();
$total_alert = $this->get_active_alert_count();
?>
<div class="wrap">
<h1>
<?php esc_html_e( 'Network Alerts', 'robotstxt-mediaaudit' ); ?>
<?php if ( $total_alert > 0 ) : ?>
<span class="title-count"><?php echo esc_html( number_format_i18n( $total_alert ) ); ?></span>
<?php endif; ?>
</h1>
<p class="description">
<?php esc_html_e( 'Active copyright-risk alerts across all sites. To dismiss an alert, visit the Alerts page on the corresponding site.', 'robotstxt-mediaaudit' ); ?>
</p>
<form method="get">
<input type="hidden" name="page" value="robotstxt-mediaaudit-network-alerts" />
<?php $table->display(); ?>
</form>
</div>
<?php
}
/**
* Returns the total count of active (non-dismissed) alerts across the network.
*
* @return int
*/
private function get_active_alert_count(): int {
global $wpdb;
$table = NetworkDatabase::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE has_alert = 1 AND is_dismissed = 0" );
}
}

View file

@ -0,0 +1,102 @@
<?php
/**
* Network admin aggregated Audit page.
*
* @package MediaRightsAudit\Network
*/
namespace MediaRightsAudit\Network;
use MediaRightsAudit\Core\NetworkDatabase;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Renders the aggregated media audit list for the network admin.
*
* Queries the network mirror table to show all media across all sites with
* a "Site" column identifying the origin of each attachment.
*/
class AuditPage {
/**
* Renders the network Audit page.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$table = new NetworkListTable( false );
$table->prepare_items();
$stats = $this->get_network_stats();
?>
<div class="wrap">
<h1><?php esc_html_e( 'Network Media Audit', 'robotstxt-mediaaudit' ); ?></h1>
<div class="mra-stats">
<?php
$this->stat_box( $stats['total_indexed'], __( 'Total Indexed', 'robotstxt-mediaaudit' ), '' );
$this->stat_box( $stats['total_matches'], __( 'External Matches', 'robotstxt-mediaaudit' ), '' );
$this->stat_box( $stats['total_alert'], __( 'Active Alerts', 'robotstxt-mediaaudit' ), '' );
$this->stat_box( $stats['total_sites'], __( 'Sites', 'robotstxt-mediaaudit' ), '' );
?>
</div>
<form method="get">
<input type="hidden" name="page" value="robotstxt-mediaaudit-network" />
<?php $table->display(); ?>
</form>
</div>
<?php
}
/**
* Renders a single stat box.
*
* @param int $value Numeric value.
* @param string $label Short label.
* @param string $sub Optional sub-label.
*
* @return void
*/
private function stat_box( int $value, string $label, string $sub ): void {
echo '<div class="mra-stat-box">';
echo '<strong>' . esc_html( number_format_i18n( $value ) ) . '</strong>';
echo '<span>' . esc_html( $label ) . '</span>';
if ( '' !== $sub ) {
echo '<small>' . esc_html( $sub ) . '</small>';
}
echo '</div>';
}
/**
* Returns aggregate stats from the network mirror table.
*
* @return array{total_indexed: int, total_matches: int, total_alert: int, total_sites: int}
*/
private function get_network_stats(): array {
global $wpdb;
$table = NetworkDatabase::table_name();
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$total_indexed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
$total_matches = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE external_status = 'matches'" );
$total_alert = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE has_alert = 1 AND is_dismissed = 0" );
$total_sites = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT site_id) FROM `{$table}`" );
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
return array(
'total_indexed' => $total_indexed,
'total_matches' => $total_matches,
'total_alert' => $total_alert,
'total_sites' => $total_sites,
);
}
}

View file

@ -0,0 +1,304 @@
<?php
/**
* WP_List_Table subclass for the cross-site media audit list.
*
* @package MediaRightsAudit\Network
*/
namespace MediaRightsAudit\Network;
use MediaRightsAudit\Core\NetworkDatabase;
use MediaRightsAudit\External\HostnameFilter;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
/**
* Renders the aggregated media list in the network admin Audit page.
*
* Queries {base_prefix}mra_network_index for fast cross-site listing.
* Detailed per-attachment data is accessed on demand via per-site URLs.
* A "Site" column is added to identify which site each attachment belongs to.
*/
class NetworkListTable extends \WP_List_Table {
/**
* Rows per page.
*/
const PER_PAGE = 30;
/**
* Alerts-only filter: when true, shows only has_alert=1 rows.
*
* @var bool
*/
private bool $alerts_only;
/**
* Initialises the list table.
*
* @param bool $alerts_only Whether to filter to alert rows only.
*/
public function __construct( bool $alerts_only = false ) {
parent::__construct(
array(
'singular' => 'mra-network-attachment',
'plural' => 'mra-network-attachments',
'ajax' => false,
)
);
$this->alerts_only = $alerts_only;
}
/**
* Returns the column definitions.
*
* @return array<string, string>
*/
public function get_columns(): array {
return array(
'cb' => '<input type="checkbox" />',
'site' => __( 'Site', 'robotstxt-mediaaudit' ),
'file_name' => __( 'Filename', 'robotstxt-mediaaudit' ),
'external_status' => __( 'External Status', 'robotstxt-mediaaudit' ),
'has_alert' => __( 'Alert', 'robotstxt-mediaaudit' ),
'external_scanned_at' => __( 'Last Scanned', 'robotstxt-mediaaudit' ),
);
}
/**
* Returns sortable column definitions.
*
* @return array<string, array<int, mixed>>
*/
protected function get_sortable_columns(): array {
return array(
'site' => array( 'site_url', false ),
'file_name' => array( 'file_name', false ),
'external_status' => array( 'external_status', false ),
'external_scanned_at' => array( 'external_scanned_at', false ),
);
}
/**
* Returns bulk action definitions.
*
* @return array<string, string>
*/
protected function get_bulk_actions(): array {
return array(
'run_external_scan' => __( 'Run External Scan', 'robotstxt-mediaaudit' ),
'purge_external_data' => __( 'Purge External Data', 'robotstxt-mediaaudit' ),
);
}
/**
* Renders the checkbox column.
*
* @param array<string, mixed> $item Row data.
*
* @return string
*/
protected function column_cb( $item ): string {
$site_id = is_numeric( $item['site_id'] ?? null ) ? (int) $item['site_id'] : 0;
$aid = is_numeric( $item['attachment_id'] ?? null ) ? (int) $item['attachment_id'] : 0;
return sprintf(
'<input type="checkbox" name="mra_item[]" value="%d_%d" />',
$site_id,
$aid
);
}
/**
* Renders the Site column.
*
* @param array<string, mixed> $item Row data.
*
* @return string
*/
protected function column_site( array $item ): string {
$site_id = is_numeric( $item['site_id'] ?? null ) ? (int) $item['site_id'] : 0;
$site_url = is_string( $item['site_url'] ?? null ) ? $item['site_url'] : '';
$site_details = get_site( $site_id );
$site_name = $site_details instanceof \WP_Site ? esc_html( $site_details->blogname ) : esc_html( $site_url );
if ( $site_url ) {
$admin_url = get_admin_url( $site_id, 'admin.php?page=robotstxt-mediaaudit' );
return sprintf(
'<a href="%s" target="_blank">%s</a><br /><small>%s</small>',
esc_url( $admin_url ),
$site_name,
esc_html( $site_url )
);
}
return $site_name;
}
/**
* Renders the Filename column with a link to the per-site detail page.
*
* @param array<string, mixed> $item Row data.
*
* @return string
*/
protected function column_file_name( array $item ): string {
$site_id = is_numeric( $item['site_id'] ?? null ) ? (int) $item['site_id'] : 0;
$aid = is_numeric( $item['attachment_id'] ?? null ) ? (int) $item['attachment_id'] : 0;
$filename = is_string( $item['file_name'] ?? null ) ? $item['file_name'] : "#{$aid}";
$detail_url = get_admin_url(
$site_id,
'admin.php?page=robotstxt-mediaaudit-detail&attachment_id=' . $aid
);
return sprintf(
'<a href="%s" target="_blank">%s</a>',
esc_url( $detail_url ),
esc_html( $filename )
);
}
/**
* Renders the External Status column.
*
* @param array<string, mixed> $item Row data.
*
* @return string
*/
protected function column_external_status( array $item ): string {
$status = is_string( $item['external_status'] ?? null ) ? $item['external_status'] : 'pending';
return '<span class="mra-badge mra-status-' . esc_attr( $status ) . '">' . esc_html( ucfirst( $status ) ) . '</span>';
}
/**
* Renders the Alert column.
*
* @param array<string, mixed> $item Row data.
*
* @return string
*/
protected function column_has_alert( array $item ): string {
$has_alert = ! empty( $item['has_alert'] );
$is_dismissed = ! empty( $item['is_dismissed'] );
if ( $has_alert && $is_dismissed ) {
return '<span class="mra-badge">' . esc_html__( 'Dismissed', 'robotstxt-mediaaudit' ) . '</span>';
}
if ( $has_alert ) {
return '<span class="mra-badge mra-badge-alert">' . esc_html__( 'Alert', 'robotstxt-mediaaudit' ) . '</span>';
}
return '&mdash;';
}
/**
* Renders the Last Scanned column.
*
* @param array<string, mixed> $item Row data.
*
* @return string
*/
protected function column_external_scanned_at( array $item ): string {
$val = $item['external_scanned_at'] ?? null;
return is_string( $val ) && '' !== $val ? esc_html( $val ) : '&mdash;';
}
/**
* Default column renderer.
*
* @param array<string, mixed> $item Row data.
* @param string $column_name Column key.
*
* @return string
*/
protected function column_default( $item, $column_name ): string {
$val = $item[ $column_name ] ?? '';
return is_scalar( $val ) ? esc_html( (string) $val ) : '';
}
/**
* Queries the network mirror table and populates $this->items.
*
* @return void
*/
public function prepare_items(): void {
global $wpdb;
$table = NetworkDatabase::table_name();
$per_pg = self::PER_PAGE;
$current = $this->get_pagenum();
$offset = ( $current - 1 ) * $per_pg;
$where = array( '1=1' );
$args = array();
if ( $this->alerts_only ) {
$where[] = 'has_alert = 1 AND is_dismissed = 0';
}
// Site filter.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$site_filter = isset( $_GET['mra_site'] ) && is_numeric( $_GET['mra_site'] ) ? (int) $_GET['mra_site'] : 0;
if ( $site_filter > 0 ) {
$where[] = 'site_id = %d';
$args[] = $site_filter;
}
// Status filter.
$valid_statuses = array( 'pending', 'queued', 'scanned', 'matches', 'error' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$status_filter = isset( $_GET['mra_status'] ) && is_string( $_GET['mra_status'] ) ? sanitize_key( $_GET['mra_status'] ) : '';
if ( $status_filter && in_array( $status_filter, $valid_statuses, true ) ) {
$where[] = 'external_status = %s';
$args[] = $status_filter;
}
$where_sql = implode( ' AND ', $where );
// Sorting.
$valid_orderby = array( 'site_url', 'file_name', 'external_status', 'external_scanned_at' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$orderby_raw = isset( $_GET['orderby'] ) && is_string( $_GET['orderby'] ) ? sanitize_key( $_GET['orderby'] ) : 'site_url';
$orderby = in_array( $orderby_raw, $valid_orderby, true ) ? $orderby_raw : 'site_url';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$order_raw = isset( $_GET['order'] ) && is_string( $_GET['order'] ) ? strtoupper( sanitize_key( $_GET['order'] ) ) : 'ASC';
$order = in_array( $order_raw, array( 'ASC', 'DESC' ), true ) ? $order_raw : 'ASC';
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$count_sql = "SELECT COUNT(*) FROM `{$table}` WHERE {$where_sql}";
$total = empty( $args ) ? (int) $wpdb->get_var( $count_sql ) : (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$args ) );
$data_sql = "SELECT * FROM `{$table}` WHERE {$where_sql} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d";
$query_args = array_merge( $args, array( $per_pg, $offset ) );
$this->items = (array) $wpdb->get_results( $wpdb->prepare( $data_sql, ...$query_args ), ARRAY_A );
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$this->set_pagination_args(
array(
'total_items' => $total,
'per_page' => $per_pg,
)
);
$this->_column_headers = array(
$this->get_columns(),
array(),
$this->get_sortable_columns(),
);
}
/**
* Renders the "no items" message.
*
* @return void
*/
public function no_items(): void {
esc_html_e( 'No media attachments found across the network.', 'robotstxt-mediaaudit' );
}
}

148
includes/Network/Plugin.php Normal file
View file

@ -0,0 +1,148 @@
<?php
/**
* Network admin bootstrap for central mode.
*
* @package MediaRightsAudit\Network
*/
namespace MediaRightsAudit\Network;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers network admin menu pages and hooks when the plugin operates in central mode.
*
* In per_site mode, only a lightweight network status page is registered so
* network admins can monitor per-site plugin health without managing data centrally.
*/
class Plugin {
/**
* Network Settings page handler.
*
* @var Settings
*/
private Settings $settings;
/**
* Network Audit page handler.
*
* @var AuditPage
*/
private AuditPage $audit_page;
/**
* Network Alerts page handler.
*
* @var AlertsPage
*/
private AlertsPage $alerts_page;
/**
* Network Tools page handler.
*
* @var ToolsPage
*/
private ToolsPage $tools_page;
/**
* Initialises network admin subsystem dependencies.
*/
public function __construct() {
$this->settings = new Settings();
$this->audit_page = new AuditPage();
$this->alerts_page = new AlertsPage();
$this->tools_page = new ToolsPage();
}
/**
* Registers all network admin hooks.
*
* @return void
*/
public function run(): void {
add_action( 'network_admin_menu', array( $this, 'register_network_pages' ) );
add_action( 'network_admin_edit_mra_network_settings', array( $this->settings, 'save' ) );
}
/**
* Registers network admin menu pages.
*
* In central mode: full aggregated UI (Audit, Alerts, Tools, Settings).
* In per_site mode: only a status overview page is registered.
*
* @return void
*/
public function register_network_pages(): void {
$mode = get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' );
if ( 'central' === $mode ) {
add_menu_page(
__( 'Network Media Audit', 'robotstxt-mediaaudit' ),
__( 'Media Audit', 'robotstxt-mediaaudit' ),
'manage_network_options',
'robotstxt-mediaaudit-network',
array( $this->audit_page, 'render' ),
'dashicons-camera',
11
);
add_submenu_page(
'robotstxt-mediaaudit-network',
__( 'Network Media Audit', 'robotstxt-mediaaudit' ),
__( 'Network Audit', 'robotstxt-mediaaudit' ),
'manage_network_options',
'robotstxt-mediaaudit-network',
array( $this->audit_page, 'render' )
);
add_submenu_page(
'robotstxt-mediaaudit-network',
__( 'Network Alerts', 'robotstxt-mediaaudit' ),
__( 'Alerts', 'robotstxt-mediaaudit' ),
'manage_network_options',
'robotstxt-mediaaudit-network-alerts',
array( $this->alerts_page, 'render' )
);
$tools_suffix = add_submenu_page(
'robotstxt-mediaaudit-network',
__( 'Network Tools', 'robotstxt-mediaaudit' ),
__( 'Tools', 'robotstxt-mediaaudit' ),
'manage_network_options',
'robotstxt-mediaaudit-network-tools',
array( $this->tools_page, 'render' )
);
if ( is_string( $tools_suffix ) ) {
$this->tools_page->set_hook_suffix( $tools_suffix );
}
} else {
$status_suffix = add_menu_page(
__( 'Media Audit — Network', 'robotstxt-mediaaudit' ),
__( 'Media Audit', 'robotstxt-mediaaudit' ),
'manage_network_options',
'robotstxt-mediaaudit-network',
array( $this->tools_page, 'render_status_only' ),
'dashicons-camera',
11
);
if ( is_string( $status_suffix ) ) {
$this->tools_page->set_hook_suffix( $status_suffix );
}
}
// Settings always shown in network admin, regardless of mode.
add_submenu_page(
'robotstxt-mediaaudit-network',
__( 'Network Settings', 'robotstxt-mediaaudit' ),
__( 'Settings', 'robotstxt-mediaaudit' ),
'manage_network_options',
'robotstxt-mediaaudit-network-settings',
array( $this->settings, 'render' )
);
}
}

View file

@ -0,0 +1,382 @@
<?php
/**
* Network admin Settings page.
*
* @package MediaRightsAudit\Network
*/
namespace MediaRightsAudit\Network;
use MediaRightsAudit\Admin\Settings as SiteSettings;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Manages network-wide plugin settings stored as site options.
*
* In central mode, all sites inherit API credentials and filter lists from the
* network option robotstxt_mediaaudit_network_settings. In per_site mode, each
* site uses its own robotstxt_mediaaudit_settings option.
*
* The mode toggle, API credentials, filter lists, and external scanning config
* are all managed here in the network admin.
*/
class Settings {
/**
* Network option key for shared settings (API keys, filters, external config).
*/
const NETWORK_OPTION = 'robotstxt_mediaaudit_network_settings';
/**
* Network option key for the operating mode.
*/
const MODE_OPTION = 'robotstxt_mediaaudit_network_mode';
/**
* Valid operating modes.
*
* @var list<string>
*/
private const VALID_MODES = array( 'per_site', 'central' );
/**
* Valid settings tabs (reuses same tab structure as per-site Settings).
*
* @var list<string>
*/
private const TAB_KEYS = array( 'mode', 'api', 'filters', 'external' );
/**
* Returns the active tab from the current request.
*
* @return string
*/
private function get_active_tab(): string {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$raw = isset( $_GET['tab'] ) && is_string( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'mode';
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'mode';
}
/**
* Handles the network_admin_edit_ action for saving settings.
*
* Redirects back to the settings page after saving.
*
* @return void
*/
public function save(): void {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to manage network settings.', 'robotstxt-mediaaudit' ) );
}
check_admin_referer( 'mra_network_settings_nonce' );
$tab_raw = isset( $_POST['_tab'] ) && is_string( $_POST['_tab'] ) ? sanitize_key( $_POST['_tab'] ) : '';
$this->save_tab( $tab_raw );
wp_safe_redirect(
add_query_arg(
array(
'page' => 'robotstxt-mediaaudit-network-settings',
'tab' => $tab_raw,
'updated' => '1',
),
network_admin_url( 'admin.php' )
)
);
exit;
}
/**
* Saves one tab's data to the appropriate site option.
*
* @param string $tab Tab key being saved.
*
* @return void
*/
private function save_tab( string $tab ): void {
if ( 'mode' === $tab ) {
$raw_mode = isset( $_POST['network_mode'] ) && is_string( $_POST['network_mode'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
? sanitize_key( $_POST['network_mode'] ) // phpcs:ignore WordPress.Security.NonceVerification.Missing
: 'per_site';
$mode = in_array( $raw_mode, self::VALID_MODES, true ) ? $raw_mode : 'per_site';
$previous_mode = get_site_option( self::MODE_OPTION, 'per_site' );
update_site_option( self::MODE_OPTION, $mode );
// Migrate settings from main site on first switch to central mode.
if ( 'central' === $mode && 'central' !== $previous_mode ) {
$this->maybe_migrate_from_main_site();
}
return;
}
$raw = get_site_option( self::NETWORK_OPTION, array() );
$current = is_array( $raw ) ? $raw : array();
$output = $current;
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( 'api' === $tab ) {
$output['google_vision_api_key'] = sanitize_text_field( wp_unslash( is_string( $_POST['google_vision_api_key'] ?? null ) ? $_POST['google_vision_api_key'] : '' ) );
$output['tineye_api_key'] = sanitize_text_field( wp_unslash( is_string( $_POST['tineye_api_key'] ?? null ) ? $_POST['tineye_api_key'] : '' ) );
$output['picdefense_user_id'] = sanitize_text_field( wp_unslash( is_string( $_POST['picdefense_user_id'] ?? null ) ? $_POST['picdefense_user_id'] : '' ) );
$output['picdefense_api_key'] = sanitize_text_field( wp_unslash( is_string( $_POST['picdefense_api_key'] ?? null ) ? $_POST['picdefense_api_key'] : '' ) );
}
if ( 'filters' === $tab ) {
$site_settings = new SiteSettings();
// sanitize_hostname_list_public() fully sanitizes the raw textarea value.
// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$output['filter_include'] = $site_settings->sanitize_hostname_list_public( wp_unslash( is_string( $_POST['filter_include'] ?? null ) ? $_POST['filter_include'] : '' ) );
$output['filter_exclude'] = $site_settings->sanitize_hostname_list_public( wp_unslash( is_string( $_POST['filter_exclude'] ?? null ) ? $_POST['filter_exclude'] : '' ) );
// phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$overlap = array_intersect( $output['filter_include'], $output['filter_exclude'] );
if ( ! empty( $overlap ) ) {
$output['filter_exclude'] = array_values( array_diff( $output['filter_exclude'], $overlap ) );
}
}
if ( 'external' === $tab ) {
$batch_val = is_numeric( $_POST['external_batch_size'] ?? null ) ? (int) $_POST['external_batch_size'] : 10;
$output['external_batch_size'] = max( 1, min( 100, $batch_val ) );
$rl_val = is_numeric( $_POST['rate_limit_per_minute'] ?? null ) ? (int) $_POST['rate_limit_per_minute'] : 10;
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl_val ) );
}
// phpcs:enable WordPress.Security.NonceVerification.Missing
update_site_option( self::NETWORK_OPTION, $output );
}
/**
* Copies the main site's settings to the network option as a starting point.
*
* Only runs once if the network option already has API credentials set,
* it is not overwritten.
*
* @return void
*/
private function maybe_migrate_from_main_site(): void {
$existing = get_site_option( self::NETWORK_OPTION, array() );
if ( is_array( $existing ) && ! empty( $existing ) ) {
return;
}
$main_site_id = get_main_site_id();
switch_to_blog( $main_site_id );
$site_settings = get_option( SiteSettings::OPTION_NAME, array() );
restore_current_blog();
if ( is_array( $site_settings ) && ! empty( $site_settings ) ) {
update_site_option( self::NETWORK_OPTION, $site_settings );
}
}
/**
* Renders the network Settings page.
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$tab = $this->get_active_tab();
$mode_raw = get_site_option( self::MODE_OPTION, 'per_site' );
$mode = is_string( $mode_raw ) ? $mode_raw : 'per_site';
$opts_raw = get_site_option( self::NETWORK_OPTION, array() );
$opts = is_array( $opts_raw ) ? $opts_raw : array();
$tabs = array(
'mode' => __( 'Mode', 'robotstxt-mediaaudit' ),
'api' => __( 'API Credentials', 'robotstxt-mediaaudit' ),
'filters' => __( 'Filters', 'robotstxt-mediaaudit' ),
'external' => __( 'External Scanning', 'robotstxt-mediaaudit' ),
);
$page_url = network_admin_url( 'admin.php?page=robotstxt-mediaaudit-network-settings' );
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$updated = isset( $_GET['updated'] ) && '1' === $_GET['updated'];
// phpcs:enable WordPress.Security.NonceVerification.Recommended
?>
<div class="wrap">
<h1><?php esc_html_e( 'Media Audit — Network Settings', 'robotstxt-mediaaudit' ); ?></h1>
<?php if ( $updated ) : ?>
<div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Settings saved.', 'robotstxt-mediaaudit' ); ?></p></div>
<?php endif; ?>
<nav class="nav-tab-wrapper">
<?php foreach ( $tabs as $key => $label ) : ?>
<a href="<?php echo esc_url( add_query_arg( 'tab', $key, $page_url ) ); ?>"
class="nav-tab<?php echo ( $tab === $key ) ? ' nav-tab-active' : ''; ?>">
<?php echo esc_html( $label ); ?>
</a>
<?php endforeach; ?>
</nav>
<form method="post" action="<?php echo esc_url( network_admin_url( 'edit.php?action=mra_network_settings' ) ); ?>">
<?php wp_nonce_field( 'mra_network_settings_nonce' ); ?>
<input type="hidden" name="_tab" value="<?php echo esc_attr( $tab ); ?>" />
<?php if ( 'mode' === $tab ) : ?>
<?php $this->render_mode_tab( $mode ); ?>
<?php elseif ( 'api' === $tab ) : ?>
<?php $this->render_api_tab( $opts ); ?>
<?php elseif ( 'filters' === $tab ) : ?>
<?php $this->render_filters_tab( $opts ); ?>
<?php elseif ( 'external' === $tab ) : ?>
<?php $this->render_external_tab( $opts ); ?>
<?php endif; ?>
<?php submit_button(); ?>
</form>
</div>
<?php
}
/**
* Renders the Mode tab.
*
* @param string $current_mode Current operating mode.
*
* @return void
*/
private function render_mode_tab( string $current_mode ): void {
?>
<h2><?php esc_html_e( 'Operating Mode', 'robotstxt-mediaaudit' ); ?></h2>
<p class="description">
<?php esc_html_e( 'Choose how Media Audit operates across this network.', 'robotstxt-mediaaudit' ); ?>
</p>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><?php esc_html_e( 'Mode', 'robotstxt-mediaaudit' ); ?></th>
<td>
<fieldset>
<label>
<input type="radio" name="network_mode" value="per_site"
<?php checked( $current_mode, 'per_site' ); ?> />
<strong><?php esc_html_e( 'Per site', 'robotstxt-mediaaudit' ); ?></strong>
</label>
<p class="description">
<?php esc_html_e( 'Each site manages its own media audit independently. No network admin aggregation. Site admins configure API credentials and run scans separately.', 'robotstxt-mediaaudit' ); ?>
</p>
<br />
<label>
<input type="radio" name="network_mode" value="central"
<?php checked( $current_mode, 'central' ); ?> />
<strong><?php esc_html_e( 'Central (network admin)', 'robotstxt-mediaaudit' ); ?></strong>
</label>
<p class="description">
<?php esc_html_e( 'API credentials and filter lists are configured here and shared across all sites. Data is stored per site but viewed and managed from this network admin panel. Site-level admin pages show a placeholder notice.', 'robotstxt-mediaaudit' ); ?>
</p>
</fieldset>
</td>
</tr>
</table>
<?php
}
/**
* Renders the API Credentials tab (mirrors per-site Settings).
*
* @param array<string, mixed> $opts Current network settings.
*
* @return void
*/
private function render_api_tab( array $opts ): void {
$gv_key = is_string( $opts['google_vision_api_key'] ?? null ) ? $opts['google_vision_api_key'] : '';
$te_key = is_string( $opts['tineye_api_key'] ?? null ) ? $opts['tineye_api_key'] : '';
$pd_uid = is_string( $opts['picdefense_user_id'] ?? null ) ? $opts['picdefense_user_id'] : '';
$pd_key = is_string( $opts['picdefense_api_key'] ?? null ) ? $opts['picdefense_api_key'] : '';
?>
<p class="description"><?php esc_html_e( 'These credentials are shared across all sites in the network (central mode only).', 'robotstxt-mediaaudit' ); ?></p>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="mra_net_gv_key"><?php esc_html_e( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ); ?></label></th>
<td><input type="text" id="mra_net_gv_key" name="google_vision_api_key" value="<?php echo esc_attr( $gv_key ); ?>" class="regular-text" autocomplete="off" /></td>
</tr>
<tr>
<th scope="row"><label for="mra_net_te_key"><?php esc_html_e( 'TinEye API Key', 'robotstxt-mediaaudit' ); ?></label></th>
<td><input type="text" id="mra_net_te_key" name="tineye_api_key" value="<?php echo esc_attr( $te_key ); ?>" class="regular-text" autocomplete="off" /></td>
</tr>
<tr>
<th scope="row"><label for="mra_net_pd_uid"><?php esc_html_e( 'PicDefense User ID', 'robotstxt-mediaaudit' ); ?></label></th>
<td><input type="text" id="mra_net_pd_uid" name="picdefense_user_id" value="<?php echo esc_attr( $pd_uid ); ?>" class="regular-text" autocomplete="off" /></td>
</tr>
<tr>
<th scope="row"><label for="mra_net_pd_key"><?php esc_html_e( 'PicDefense API Key', 'robotstxt-mediaaudit' ); ?></label></th>
<td><input type="password" id="mra_net_pd_key" name="picdefense_api_key" value="<?php echo esc_attr( $pd_key ); ?>" class="regular-text" autocomplete="off" /></td>
</tr>
</table>
<?php
}
/**
* Renders the Filters tab.
*
* @param array<string, mixed> $opts Current network settings.
*
* @return void
*/
private function render_filters_tab( array $opts ): void {
$include_raw = $opts['filter_include'] ?? array();
$exclude_raw = $opts['filter_exclude'] ?? array();
$include_list = is_array( $include_raw ) ? array_filter( $include_raw, 'is_string' ) : array();
$exclude_list = is_array( $exclude_raw ) ? array_filter( $exclude_raw, 'is_string' ) : array();
?>
<p class="description"><?php esc_html_e( 'Hostname lists applied to all sites in the network (central mode only).', 'robotstxt-mediaaudit' ); ?></p>
<h3><?php esc_html_e( 'Alert Hostnames', 'robotstxt-mediaaudit' ); ?></h3>
<p class="description"><?php esc_html_e( 'Matches from these hostnames are flagged as copyright alerts. One hostname per line.', 'robotstxt-mediaaudit' ); ?></p>
<textarea name="filter_include" rows="8" class="large-text code"><?php echo esc_textarea( implode( "\n", $include_list ) ); ?></textarea>
<h3><?php esc_html_e( 'Ignored Hostnames', 'robotstxt-mediaaudit' ); ?></h3>
<p class="description"><?php esc_html_e( 'Matches from these hostnames are silently ignored. One hostname per line.', 'robotstxt-mediaaudit' ); ?></p>
<textarea name="filter_exclude" rows="8" class="large-text code"><?php echo esc_textarea( implode( "\n", $exclude_list ) ); ?></textarea>
<?php
}
/**
* Renders the External Scanning tab.
*
* @param array<string, mixed> $opts Current network settings.
*
* @return void
*/
private function render_external_tab( array $opts ): void {
$batch_size = is_numeric( $opts['external_batch_size'] ?? null ) ? (int) $opts['external_batch_size'] : 10;
$rate_limit = is_numeric( $opts['rate_limit_per_minute'] ?? null ) ? (int) $opts['rate_limit_per_minute'] : 10;
?>
<table class="form-table" role="presentation">
<tr>
<th scope="row"><label for="mra_net_batch"><?php esc_html_e( 'Batch Size', 'robotstxt-mediaaudit' ); ?></label></th>
<td>
<input type="number" id="mra_net_batch" name="external_batch_size"
value="<?php echo esc_attr( (string) $batch_size ); ?>" min="1" max="100" class="small-text" />
<p class="description"><?php esc_html_e( 'Images to scan per scheduled batch per site (1100). Default: 10.', 'robotstxt-mediaaudit' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="mra_net_rl"><?php esc_html_e( 'Rate Limit (req/min)', 'robotstxt-mediaaudit' ); ?></label></th>
<td>
<input type="number" id="mra_net_rl" name="rate_limit_per_minute"
value="<?php echo esc_attr( (string) $rate_limit ); ?>" min="1" max="60" class="small-text" />
<p class="description"><?php esc_html_e( 'Maximum API requests per minute per provider per site (160). Default: 10.', 'robotstxt-mediaaudit' ); ?></p>
</td>
</tr>
</table>
<?php
}
}

View file

@ -0,0 +1,470 @@
<?php
/**
* Network admin Tools page.
*
* @package MediaRightsAudit\Network
*/
namespace MediaRightsAudit\Network;
use MediaRightsAudit\Core\NetworkActivator;
use MediaRightsAudit\Core\Queue\Scheduler;
use MediaRightsAudit\External\ExternalScanner;
use MediaRightsAudit\Internal\AttachmentIndexer;
use MediaRightsAudit\Internal\UsageScanner;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Renders the network Tools page and handles cross-site scan triggers.
*
* Scans are dispatched via Action Scheduler per site: for each active site,
* switch_to_blog() activates that site's context before calling schedule().
* Action Scheduler captures the blog_id at schedule time and restores it
* automatically when the job runs.
*
* A per-site status table is shown, cached as a 5-minute transient to avoid
* expensive switch_to_blog loops on every page load.
*/
class ToolsPage {
/**
* Nonce action for scan trigger operations.
*/
const NONCE_ACTION = 'mra_network_tools_op';
/**
* Nonce field name.
*/
const NONCE_FIELD = 'mra_network_tools_nonce';
/**
* AJAX nonce for scan triggers.
*/
const NONCE_AJAX = 'mra_network_trigger_scan';
/**
* Transient key for cached per-site status.
*/
const STATUS_TRANSIENT = 'mra_network_status';
/**
* Registers the load-* hook for PRG processing on this page.
*
* @param string $suffix Hook suffix returned by add_*_page().
*
* @return void
*/
public function set_hook_suffix( string $suffix ): void {
add_action( 'load-' . $suffix, array( $this, 'handle_load' ) );
}
/**
* Fires before the page HTML is output; processes POST operations and redirects.
*
* Implements the PRG (Post/Redirect/Get) pattern so refreshing the page
* does not re-submit the operation.
*
* @return void
*/
public function handle_load(): void {
if ( ! isset( $_POST['mra_network_op'] ) ) {
return;
}
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'Insufficient permissions.', 'robotstxt-mediaaudit' ) );
}
check_admin_referer( self::NONCE_ACTION, self::NONCE_FIELD );
$op = sanitize_key( wp_unslash( is_string( $_POST['mra_network_op'] ) ? $_POST['mra_network_op'] : '' ) );
$valid_ops = array( 'internal', 'external', 'requeue_errors' );
if ( ! in_array( $op, $valid_ops, true ) ) {
wp_safe_redirect( add_query_arg( 'mra_notice', 'error', $this->page_url() ) );
exit;
}
$site_ids = NetworkActivator::get_active_site_ids();
$count = 0;
foreach ( $site_ids as $site_id ) {
switch_to_blog( $site_id );
switch ( $op ) {
case 'internal':
AttachmentIndexer::schedule();
UsageScanner::schedule();
++$count;
break;
case 'external':
ExternalScanner::schedule();
++$count;
break;
case 'requeue_errors':
ExternalScanner::requeue_errors();
++$count;
break;
}
restore_current_blog();
}
delete_transient( self::STATUS_TRANSIENT );
wp_safe_redirect(
add_query_arg(
array(
'mra_notice' => 'success',
'mra_op' => $op,
'mra_count' => $count,
),
$this->page_url()
)
);
exit;
}
/**
* Returns the canonical URL for the Network Tools page.
*
* @return string
*/
private function page_url(): string {
return network_admin_url( 'admin.php?page=robotstxt-mediaaudit-network-tools' );
}
/**
* Renders the full network Tools page (used in central mode).
*
* @return void
*/
public function render(): void {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$status = $this->get_network_status();
$notice = $this->get_notice_data();
?>
<div class="wrap">
<h1><?php esc_html_e( 'Network Tools', 'robotstxt-mediaaudit' ); ?></h1>
<?php $this->render_notice( $notice ); ?>
<h2><?php esc_html_e( 'Per-Site Status', 'robotstxt-mediaaudit' ); ?></h2>
<?php $this->render_site_status_table( $status ); ?>
<h2 class="mra-ops-heading"><?php esc_html_e( 'Network Operations', 'robotstxt-mediaaudit' ); ?></h2>
<?php $this->render_network_operations(); ?>
</div>
<?php
}
/**
* Renders only the per-site status overview (used in per_site mode).
*
* @return void
*/
public function render_status_only(): void {
if ( ! current_user_can( 'manage_network_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$status = $this->get_network_status();
?>
<div class="wrap">
<h1><?php esc_html_e( 'Media Audit — Network Overview', 'robotstxt-mediaaudit' ); ?></h1>
<p class="description">
<?php esc_html_e( 'Currently running in per-site mode. Each site manages its own audit independently. Switch to central mode in Network Settings to manage all sites from here.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php $this->render_site_status_table( $status ); ?>
</div>
<?php
}
/**
* Renders the per-site status table.
*
* @param array<int, array<string, mixed>> $status Per-site status data keyed by site_id.
*
* @return void
*/
private function render_site_status_table( array $status ): void {
?>
<table class="widefat striped mra-status-table">
<thead>
<tr>
<th><?php esc_html_e( 'Site', 'robotstxt-mediaaudit' ); ?></th>
<th><?php esc_html_e( 'DB Schema', 'robotstxt-mediaaudit' ); ?></th>
<th><?php esc_html_e( 'Indexed', 'robotstxt-mediaaudit' ); ?></th>
<th><?php esc_html_e( 'External', 'robotstxt-mediaaudit' ); ?></th>
<th><?php esc_html_e( 'Alerts', 'robotstxt-mediaaudit' ); ?></th>
<th><?php esc_html_e( 'Pending Jobs', 'robotstxt-mediaaudit' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $status as $site_id => $s ) : ?>
<?php
if ( ! is_array( $s ) ) {
continue;
}
$site_name = is_string( $s['site_name'] ?? null ) ? $s['site_name'] : '';
$audit_url = is_string( $s['audit_url'] ?? null ) ? $s['audit_url'] : '';
$site_url = is_string( $s['site_url'] ?? null ) ? $s['site_url'] : '';
$db_ok = ! empty( $s['db_ok'] );
$indexed = is_numeric( $s['indexed'] ?? null ) ? (int) $s['indexed'] : 0;
$total_img = is_numeric( $s['total_images'] ?? null ) ? (int) $s['total_images'] : 0;
$total_alert = is_numeric( $s['total_alert'] ?? null ) ? (int) $s['total_alert'] : 0;
$ext = is_array( $s['external'] ?? null ) ? $s['external'] : array();
$jobs = is_array( $s['jobs'] ?? null ) ? $s['jobs'] : array();
$any_job = array_filter(
array(
! empty( $jobs['index'] ),
! empty( $jobs['usage'] ),
! empty( $jobs['external'] ),
)
);
?>
<tr>
<td>
<strong><?php echo esc_html( $site_name ); ?></strong><br />
<a href="<?php echo esc_url( $audit_url ); ?>" target="_blank">
<small><?php echo esc_html( $site_url ); ?></small>
</a>
</td>
<td><?php echo $db_ok ? '<span style="color:green">✓</span>' : '<span style="color:red">✗</span>'; ?></td>
<td>
<?php
printf(
/* translators: 1: indexed 2: total */
esc_html__( '%1$s / %2$s', 'robotstxt-mediaaudit' ),
esc_html( number_format_i18n( $indexed ) ),
esc_html( number_format_i18n( $total_img ) )
);
?>
</td>
<td>
<?php
printf(
/* translators: 1: scanned 2: matches 3: errors */
esc_html__( '%1$s scanned · %2$s matches · %3$s errors', 'robotstxt-mediaaudit' ),
esc_html( number_format_i18n( is_numeric( $ext['scanned'] ?? null ) ? (int) $ext['scanned'] : 0 ) ),
esc_html( number_format_i18n( is_numeric( $ext['matches'] ?? null ) ? (int) $ext['matches'] : 0 ) ),
esc_html( number_format_i18n( is_numeric( $ext['error'] ?? null ) ? (int) $ext['error'] : 0 ) )
);
?>
</td>
<td><?php echo esc_html( number_format_i18n( $total_alert ) ); ?></td>
<td>
<?php echo $any_job ? esc_html__( 'Running', 'robotstxt-mediaaudit' ) : '&mdash;'; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description">
<?php esc_html_e( 'Status cached for 5 minutes.', 'robotstxt-mediaaudit' ); ?>
<a href="<?php echo esc_url( add_query_arg( 'mra_refresh', '1' ) ); ?>">
<?php esc_html_e( 'Refresh now', 'robotstxt-mediaaudit' ); ?>
</a>
</p>
<?php
}
/**
* Renders operation cards with plain POST forms (no JavaScript required).
*
* @return void
*/
private function render_network_operations(): void {
$ops = array(
array(
'op' => 'internal',
'label' => __( 'Schedule Internal Scan — All Sites', 'robotstxt-mediaaudit' ),
'desc' => __( 'Queues indexing and usage scanning jobs on every active site. Safe to run at any time — skips already-indexed attachments.', 'robotstxt-mediaaudit' ),
'class' => 'button button-primary',
),
array(
'op' => 'external',
'label' => __( 'Schedule External Scan — All Sites', 'robotstxt-mediaaudit' ),
'desc' => __( 'Queues reverse-image-search jobs on every active site. Requires API keys configured in Network Settings.', 'robotstxt-mediaaudit' ),
'class' => 'button button-primary',
),
array(
'op' => 'requeue_errors',
'label' => __( 'Requeue Scan Errors — All Sites', 'robotstxt-mediaaudit' ),
'desc' => __( 'Re-queues attachments that failed with an error on the last external scan run across all sites.', 'robotstxt-mediaaudit' ),
'class' => 'button',
),
);
?>
<div class="mra-ops-grid">
<?php foreach ( $ops as $op ) : ?>
<div class="mra-op-card">
<h4 class="mra-op-card-title"><?php echo esc_html( $op['label'] ); ?></h4>
<p class="mra-op-card-desc description"><?php echo esc_html( $op['desc'] ); ?></p>
<form method="post">
<?php wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD ); ?>
<input type="hidden" name="mra_network_op" value="<?php echo esc_attr( $op['op'] ); ?>" />
<button type="submit" class="<?php echo esc_attr( $op['class'] ); ?>">
<?php esc_html_e( 'Schedule', 'robotstxt-mediaaudit' ); ?>
</button>
</form>
</div>
<?php endforeach; ?>
</div>
<?php
}
/**
* Reads PRG query parameters and returns structured notice data.
*
* @return array{type: string, op: string, count: int}|null
*/
private function get_notice_data(): ?array {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['mra_notice'] ) ) {
return null;
}
$type = sanitize_key( wp_unslash( is_string( $_GET['mra_notice'] ) ? $_GET['mra_notice'] : '' ) );
$op = sanitize_key( wp_unslash( isset( $_GET['mra_op'] ) && is_string( $_GET['mra_op'] ) ? $_GET['mra_op'] : '' ) );
$count = isset( $_GET['mra_count'] ) && is_numeric( $_GET['mra_count'] ) ? abs( (int) $_GET['mra_count'] ) : 0;
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return array(
'type' => $type,
'op' => $op,
'count' => $count,
);
}
/**
* Renders a WP admin notice for a completed network operation.
*
* @param array{type: string, op: string, count: int}|null $notice Notice data.
*
* @return void
*/
private function render_notice( ?array $notice ): void {
if ( null === $notice || 'success' !== $notice['type'] ) {
return;
}
$op = $notice['op'];
$count = $notice['count'];
?>
<div class="notice notice-success is-dismissible">
<p>
<?php
$n = number_format_i18n( $count );
if ( 'internal' === $op ) {
/* translators: %s: number of sites */
echo esc_html( sprintf( __( 'Internal scan scheduled on %s sites.', 'robotstxt-mediaaudit' ), $n ) );
} elseif ( 'external' === $op ) {
/* translators: %s: number of sites */
echo esc_html( sprintf( __( 'External scan scheduled on %s sites.', 'robotstxt-mediaaudit' ), $n ) );
} elseif ( 'requeue_errors' === $op ) {
/* translators: %s: number of sites */
echo esc_html( sprintf( __( 'Errors re-queued on %s sites.', 'robotstxt-mediaaudit' ), $n ) );
} else {
/* translators: %s: number of sites */
echo esc_html( sprintf( __( 'Operation completed on %s sites.', 'robotstxt-mediaaudit' ), $n ) );
}
?>
</p>
</div>
<?php
}
/**
* Returns per-site status data, cached as a transient.
*
* Loops over all active sites with switch_to_blog() to collect key metrics.
*
* @return array<int, array<string, mixed>>
*/
private function get_network_status(): array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
$force_refresh = isset( $_GET['mra_refresh'] ) && '1' === $_GET['mra_refresh'];
if ( ! $force_refresh ) {
$cached = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $cached ) ) {
$typed = array();
foreach ( $cached as $k => $v ) {
if ( is_int( $k ) && is_array( $v ) ) {
$typed[ $k ] = $v;
}
}
return $typed;
}
}
global $wpdb;
$site_ids = NetworkActivator::get_active_site_ids();
$result = array();
$as_ok = Scheduler::is_available();
foreach ( $site_ids as $site_id ) {
switch_to_blog( $site_id );
$site_obj = get_site( $site_id );
$site_name = $site_obj instanceof \WP_Site ? $site_obj->blogname : (string) $site_id;
$site_url = get_site_url( $site_id );
$audit_url = get_admin_url( $site_id, 'admin.php?page=robotstxt-mediaaudit' );
$raw_ver = get_option( 'robotstxt_mediaaudit_db_version', '' );
$stored_ver = is_string( $raw_ver ) ? $raw_ver : '';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$total_images = (int) $wpdb->get_var(
"SELECT COUNT(ID) FROM {$wpdb->posts}
WHERE post_type = 'attachment'
AND post_mime_type LIKE 'image/%'
AND post_status != 'trash'"
);
$indexed = AttachmentIndexer::get_indexed_count();
$ext = ExternalScanner::get_status_counts();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$alert_count = (int) $wpdb->get_var(
"SELECT COUNT(DISTINCT er.attachment_id)
FROM {$wpdb->prefix}mra_external_results er
WHERE NOT EXISTS (
SELECT 1 FROM {$wpdb->prefix}mra_dismissed_alerts da
WHERE da.attachment_id = er.attachment_id
)"
);
$result[ $site_id ] = array(
'site_name' => $site_name,
'site_url' => $site_url,
'audit_url' => $audit_url,
'db_ok' => ROBOTSTXT_MEDIAAUDIT_DB_VERSION === $stored_ver,
'total_images' => $total_images,
'indexed' => $indexed,
'external' => $ext,
'total_alert' => $alert_count,
'jobs' => array(
'index' => $as_ok && Scheduler::has_pending( AttachmentIndexer::AS_HOOK ),
'usage' => $as_ok && Scheduler::has_pending( UsageScanner::AS_HOOK ),
'external' => $as_ok && Scheduler::has_pending( ExternalScanner::AS_HOOK ),
),
);
restore_current_blog();
}
set_transient( self::STATUS_TRANSIENT, $result, 5 * MINUTE_IN_SECONDS );
return $result;
}
}