v1.8.1
This commit is contained in:
parent
7b7fff5246
commit
b6a55a7f04
27 changed files with 2221 additions and 45 deletions
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