470 lines
15 KiB
PHP
470 lines
15 KiB
PHP
<?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;
|
|
}
|
|
}
|