v1.8.1
This commit is contained in:
parent
7b7fff5246
commit
b6a55a7f04
27 changed files with 2221 additions and 45 deletions
73
includes/Network/AlertsPage.php
Normal file
73
includes/Network/AlertsPage.php
Normal 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" );
|
||||
}
|
||||
}
|
||||
102
includes/Network/AuditPage.php
Normal file
102
includes/Network/AuditPage.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
304
includes/Network/NetworkListTable.php
Normal file
304
includes/Network/NetworkListTable.php
Normal 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 '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ) : '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
148
includes/Network/Plugin.php
Normal 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' )
|
||||
);
|
||||
}
|
||||
}
|
||||
382
includes/Network/Settings.php
Normal file
382
includes/Network/Settings.php
Normal 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 (1–100). 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 (1–60). Default: 10.', 'robotstxt-mediaaudit' ); ?></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
470
includes/Network/ToolsPage.php
Normal file
470
includes/Network/ToolsPage.php
Normal 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' ) : '—'; ?>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue