robotstxt-mediaaudit/includes/Network/ToolsPage.php
2026-08-17 18:51:45 +00:00

477 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' ) : '&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 ) ) {
continue;
}
$row = array();
foreach ( $v as $row_key => $row_value ) {
if ( is_string( $row_key ) ) {
$row[ $row_key ] = $row_value;
}
}
$typed[ $k ] = $row;
}
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;
}
}