v1.8.1
This commit is contained in:
parent
7b7fff5246
commit
b6a55a7f04
27 changed files with 2221 additions and 45 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
/* global mraAdmin, mraTools */
|
/* global mraAdmin, mraTools, mraAlerts, mraNetworkTools */
|
||||||
( function ( $ ) {
|
( function ( $ ) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|
@ -163,5 +163,39 @@
|
||||||
$row.prop( 'hidden', true );
|
$row.prop( 'hidden', true );
|
||||||
$( '[data-target="' + rowId + '"]' ).text( mraAlerts.i18n.dismiss );
|
$( '[data-target="' + rowId + '"]' ).text( mraAlerts.i18n.dismiss );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
// Network trigger buttons — Network Tools page.
|
||||||
|
if ( typeof mraNetworkTools !== 'undefined' ) {
|
||||||
|
$( document ).on( 'click', '.mra-network-trigger', function () {
|
||||||
|
var $btn = $( this );
|
||||||
|
var $status = $btn.closest( '.mra-op-card' ).find( '.mra-network-trigger-status' );
|
||||||
|
var type = $btn.data( 'type' );
|
||||||
|
|
||||||
|
$btn.prop( 'disabled', true );
|
||||||
|
$status.text( mraNetworkTools.i18n.scheduling );
|
||||||
|
|
||||||
|
$.ajax( {
|
||||||
|
url: mraNetworkTools.ajaxUrl,
|
||||||
|
type: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'mra_network_trigger_scan',
|
||||||
|
type: type,
|
||||||
|
nonce: mraNetworkTools.nonce
|
||||||
|
},
|
||||||
|
success: function ( res ) {
|
||||||
|
if ( res.success ) {
|
||||||
|
$status.text( res.data.message || mraNetworkTools.i18n.done );
|
||||||
|
} else {
|
||||||
|
$status.text( mraNetworkTools.i18n.error );
|
||||||
|
}
|
||||||
|
$btn.prop( 'disabled', false );
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
$status.text( mraNetworkTools.i18n.error );
|
||||||
|
$btn.prop( 'disabled', false );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
}
|
||||||
} );
|
} );
|
||||||
} ( jQuery ) );
|
} ( jQuery ) );
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,32 @@
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.8.1 =
|
||||||
|
|
||||||
|
_Release date: 2026-06-08_
|
||||||
|
|
||||||
|
**Fixed**
|
||||||
|
|
||||||
|
* Network Operations buttons now use standard HTML POST forms with server-side redirect (PRG pattern), replacing the AJAX approach that failed due to network admin script-loading constraints.
|
||||||
|
* Removed unreliable in-render script enqueueing for Network Tools page; operation buttons now work without JavaScript.
|
||||||
|
* JS dismiss/cancel labels on the Alerts page now use i18n strings from `wp_localize_script`.
|
||||||
|
|
||||||
|
= 1.8.0 =
|
||||||
|
|
||||||
|
_Release date: 2026-06-08_
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
|
||||||
|
* **WordPress Multisite support** — plugin now declares `Network: true` and can be activated network-wide.
|
||||||
|
* **Two operating modes** configurable from Network Admin → Media Audit → Settings:
|
||||||
|
* **Per site** (default) — each site operates independently, identical to v1.7.x behaviour.
|
||||||
|
* **Central** — API credentials and filter lists are configured once in the network admin and shared across all sites. Site-level admin pages show a placeholder notice linking to the network admin panel.
|
||||||
|
* **Network admin panel** (central mode only): aggregated Audit list, Alerts list, and Tools page showing all media across all sites with a "Site" column identifying origin. Network Tools page allows triggering internal and external scans across all sites via Action Scheduler.
|
||||||
|
* **Network mirror table** `{base_prefix}mra_network_index` — lightweight denormalised index enabling fast cross-site queries without per-request `switch_to_blog()` loops. Synchronised automatically after each scan batch.
|
||||||
|
* **New site provisioning** — when the plugin is network-active, tables are automatically created for newly added sites via the `wp_initialize_site` hook.
|
||||||
|
* **Settings migration** — on first switch to central mode, existing settings from the main site are copied to the network option as a starting point.
|
||||||
|
* **Network-aware uninstall** — when `delete_on_uninstall` is set, uninstalling removes tables and options from every site in the network plus the mirror table.
|
||||||
|
* DB schema version bumped to `1.3.0`.
|
||||||
|
|
||||||
= 1.7.1 =
|
= 1.7.1 =
|
||||||
|
|
||||||
_Release date: 2026-06-02_
|
_Release date: 2026-06-02_
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,10 @@ class AlertsPage {
|
||||||
|
|
||||||
delete_transient( 'mra_alert_count' );
|
delete_transient( 'mra_alert_count' );
|
||||||
self::invalidate_cache();
|
self::invalidate_cache();
|
||||||
|
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
\MediaRightsAudit\Core\NetworkDatabase::update_dismissal_flag( get_current_blog_id(), $attachment_id, true );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -158,6 +162,10 @@ class AlertsPage {
|
||||||
|
|
||||||
delete_transient( 'mra_alert_count' );
|
delete_transient( 'mra_alert_count' );
|
||||||
self::invalidate_cache();
|
self::invalidate_cache();
|
||||||
|
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
\MediaRightsAudit\Core\NetworkDatabase::update_dismissal_flag( get_current_blog_id(), $attachment_id, false );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,40 @@ class Settings {
|
||||||
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'general';
|
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'general';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the effective settings for the current context.
|
||||||
|
*
|
||||||
|
* In Multisite central mode, reads from the shared network site option so that
|
||||||
|
* all providers and scanners pick up network-wide API credentials and filter
|
||||||
|
* lists without changing per-site DB records.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function get_effective_settings(): array {
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
$raw = get_site_option( 'robotstxt_mediaaudit_network_settings', array() );
|
||||||
|
} else {
|
||||||
|
$raw = get_option( self::OPTION_NAME, array() );
|
||||||
|
}
|
||||||
|
return is_array( $raw ) ? $raw : array();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a settings array for the network admin, merging against $current.
|
||||||
|
*
|
||||||
|
* Mirrors the tab-based logic of sanitize() but accepts the existing values
|
||||||
|
* as a parameter rather than reading from get_option(), so it can be used
|
||||||
|
* for both per-site and network site options.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input Raw POST input (must include '_tab').
|
||||||
|
* @param array<string, mixed> $current Existing option values to merge against.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function sanitize_for_network( array $input, array $current ): array {
|
||||||
|
return $this->sanitize_with_base( $input, $current );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers all settings, sections, and fields via the WordPress Settings API.
|
* Registers all settings, sections, and fields via the WordPress Settings API.
|
||||||
*
|
*
|
||||||
|
|
@ -367,6 +401,18 @@ class Settings {
|
||||||
public function sanitize( $input ): array {
|
public function sanitize( $input ): array {
|
||||||
$raw = get_option( self::OPTION_NAME, array() );
|
$raw = get_option( self::OPTION_NAME, array() );
|
||||||
$current = is_array( $raw ) ? $raw : array();
|
$current = is_array( $raw ) ? $raw : array();
|
||||||
|
return $this->sanitize_with_base( $input, $current );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core tab-based sanitization logic shared by sanitize() and sanitize_for_network().
|
||||||
|
*
|
||||||
|
* @param mixed $input Raw POST input.
|
||||||
|
* @param array<string, mixed> $current Existing values to merge against.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function sanitize_with_base( $input, array $current ): array {
|
||||||
$output = $current;
|
$output = $current;
|
||||||
|
|
||||||
if ( ! is_array( $input ) ) {
|
if ( ! is_array( $input ) ) {
|
||||||
|
|
@ -430,11 +476,18 @@ class Settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitises a newline-separated list of hostnames into a deduplicated array.
|
* Public proxy for sanitize_hostname_list(), used by Network\Settings.
|
||||||
*
|
*
|
||||||
* Accepts plain hostnames (example.com) and explicit wildcard prefixes
|
* @param mixed $raw Raw textarea value.
|
||||||
* (*.example.com). A plain hostname implicitly covers all its subdomains
|
*
|
||||||
* at match time.
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function sanitize_hostname_list_public( mixed $raw ): array {
|
||||||
|
return $this->sanitize_hostname_list( $raw );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitises a newline-separated hostname list into a deduplicated sorted array.
|
||||||
*
|
*
|
||||||
* @param mixed $raw Raw textarea value.
|
* @param mixed $raw Raw textarea value.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use MediaRightsAudit\Admin\Settings;
|
||||||
use MediaRightsAudit\Core\Database;
|
use MediaRightsAudit\Core\Database;
|
||||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||||
use MediaRightsAudit\External\ExternalScanner;
|
use MediaRightsAudit\External\ExternalScanner;
|
||||||
|
|
@ -132,8 +133,7 @@ class ToolsPage {
|
||||||
|
|
||||||
case 'external':
|
case 'external':
|
||||||
ExternalScanner::queue_all_pending();
|
ExternalScanner::queue_all_pending();
|
||||||
$raw_opts = get_option( 'robotstxt_mediaaudit_settings', array() );
|
$opts_arr = Settings::get_effective_settings();
|
||||||
$opts_arr = is_array( $raw_opts ) ? $raw_opts : array();
|
|
||||||
$bs_setting = $opts_arr['external_batch_size'] ?? null;
|
$bs_setting = $opts_arr['external_batch_size'] ?? null;
|
||||||
$ajax_bs = is_numeric( $bs_setting ) ? max( 1, (int) $bs_setting ) : 10;
|
$ajax_bs = is_numeric( $bs_setting ) ? max( 1, (int) $bs_setting ) : 10;
|
||||||
ExternalScanner::scan_batch( $ajax_bs );
|
ExternalScanner::scan_batch( $ajax_bs );
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use MediaRightsAudit\Core\NetworkDatabase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages all custom table creation and schema migrations.
|
* Manages all custom table creation and schema migrations.
|
||||||
*
|
*
|
||||||
|
|
@ -29,6 +31,7 @@ class Database {
|
||||||
self::migration_101();
|
self::migration_101();
|
||||||
self::migration_110();
|
self::migration_110();
|
||||||
self::migration_120();
|
self::migration_120();
|
||||||
|
self::migration_130();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,6 +60,10 @@ class Database {
|
||||||
if ( version_compare( $current, '1.2.0', '<' ) ) {
|
if ( version_compare( $current, '1.2.0', '<' ) ) {
|
||||||
self::migration_120();
|
self::migration_120();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( version_compare( $current, '1.3.0', '<' ) ) {
|
||||||
|
self::migration_130();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,6 +99,10 @@ class Database {
|
||||||
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||||
|
|
||||||
delete_transient( 'mra_alert_count' );
|
delete_transient( 'mra_alert_count' );
|
||||||
|
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
NetworkDatabase::clear_site( get_current_blog_id() );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -256,4 +267,23 @@ class Database {
|
||||||
) {$charset_collate};"
|
) {$charset_collate};"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration 1.3.0 — creates the network mirror table (Multisite central mode only).
|
||||||
|
*
|
||||||
|
* For single-site installs this is a no-op. The network table lives in the
|
||||||
|
* main site's DB and is managed by NetworkDatabase::create_network_table().
|
||||||
|
* This migration entry exists only to advance the per-site DB version constant.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private static function migration_130(): void {
|
||||||
|
if ( ! is_multisite() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
NetworkDatabase::create_network_table();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
includes/Core/NetworkActivator.php
Normal file
114
includes/Core/NetworkActivator.php
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Handles network-wide activation and deactivation in WordPress Multisite.
|
||||||
|
*
|
||||||
|
* @package MediaRightsAudit\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace MediaRightsAudit\Core;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages network-level activation, deactivation, and new-site provisioning.
|
||||||
|
*
|
||||||
|
* On network activation, iterates all active sites and runs Activator::activate()
|
||||||
|
* in each site's context. On new site creation, provisions tables automatically
|
||||||
|
* when the plugin is network-active.
|
||||||
|
*/
|
||||||
|
class NetworkActivator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on network-wide plugin activation.
|
||||||
|
*
|
||||||
|
* Creates the shared network mirror table, seeds the network mode option
|
||||||
|
* (default: per_site), and runs per-site activation for every active site.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function network_activate(): void {
|
||||||
|
if ( ! is_multisite() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkDatabase::create_network_table();
|
||||||
|
|
||||||
|
if ( false === get_site_option( 'robotstxt_mediaaudit_network_mode' ) ) {
|
||||||
|
add_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' );
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( self::get_active_site_ids() as $site_id ) {
|
||||||
|
switch_to_blog( $site_id );
|
||||||
|
Activator::activate();
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs on network-wide plugin deactivation.
|
||||||
|
*
|
||||||
|
* Cancels all scheduled Action Scheduler jobs on every site. Tables and
|
||||||
|
* data are preserved (deactivation is reversible).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function network_deactivate(): void {
|
||||||
|
if ( ! is_multisite() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ( self::get_active_site_ids() as $site_id ) {
|
||||||
|
switch_to_blog( $site_id );
|
||||||
|
Deactivator::deactivate();
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provisions plugin tables for a newly created site when the plugin is network-active.
|
||||||
|
*
|
||||||
|
* Hooked to wp_initialize_site (WordPress 5.1+).
|
||||||
|
*
|
||||||
|
* @param \WP_Site $new_site The newly created site object.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function on_new_site( \WP_Site $new_site ): void {
|
||||||
|
if ( ! is_multisite() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! is_plugin_active_for_network( plugin_basename( ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE ) ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch_to_blog( (int) $new_site->blog_id );
|
||||||
|
Activator::activate();
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of active site IDs on this network.
|
||||||
|
*
|
||||||
|
* @return array<int>
|
||||||
|
*/
|
||||||
|
public static function get_active_site_ids(): array {
|
||||||
|
if ( ! is_multisite() ) {
|
||||||
|
return array( get_current_blog_id() );
|
||||||
|
}
|
||||||
|
|
||||||
|
$sites = get_sites(
|
||||||
|
array(
|
||||||
|
'fields' => 'ids',
|
||||||
|
'number' => 0,
|
||||||
|
'archived' => 0,
|
||||||
|
'deleted' => 0,
|
||||||
|
'spam' => 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return is_array( $sites ) ? array_map( 'intval', $sites ) : array();
|
||||||
|
}
|
||||||
|
}
|
||||||
261
includes/Core/NetworkDatabase.php
Normal file
261
includes/Core/NetworkDatabase.php
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Network-level database operations for the cross-site mirror index.
|
||||||
|
*
|
||||||
|
* @package MediaRightsAudit\Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace MediaRightsAudit\Core;
|
||||||
|
|
||||||
|
use MediaRightsAudit\External\HostnameFilter;
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages {base_prefix}mra_network_index, a denormalized mirror of each site's
|
||||||
|
* mra_media_index that enables fast cross-site queries in the network admin.
|
||||||
|
*
|
||||||
|
* Detailed data (top_domains JSON, raw_response) stays in per-site tables and
|
||||||
|
* is loaded on demand via switch_to_blog(). This table holds only the fields
|
||||||
|
* needed for list display, filtering, and aggregated stats.
|
||||||
|
*
|
||||||
|
* All sync methods must be called while the target site's context is active
|
||||||
|
* (switch_to_blog already called), so $wpdb->prefix resolves to per-site tables
|
||||||
|
* while $wpdb->base_prefix always resolves to the network-wide prefix.
|
||||||
|
*/
|
||||||
|
class NetworkDatabase {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the fully-qualified network mirror table name.
|
||||||
|
*
|
||||||
|
* Uses base_prefix so it lives in the main site's DB regardless of active blog.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function table_name(): string {
|
||||||
|
global $wpdb;
|
||||||
|
return $wpdb->base_prefix . 'mra_network_index';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the network mirror table using dbDelta (idempotent).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function create_network_table(): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$charset_collate = $wpdb->get_charset_collate();
|
||||||
|
$table = self::table_name();
|
||||||
|
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||||
|
|
||||||
|
dbDelta(
|
||||||
|
"CREATE TABLE {$table} (
|
||||||
|
site_id bigint(20) unsigned NOT NULL,
|
||||||
|
attachment_id bigint(20) unsigned NOT NULL,
|
||||||
|
site_url varchar(255) NOT NULL DEFAULT '',
|
||||||
|
file_name varchar(255) NOT NULL DEFAULT '',
|
||||||
|
file_url text NOT NULL,
|
||||||
|
mime_type varchar(100) NOT NULL DEFAULT '',
|
||||||
|
file_size bigint(20) unsigned NOT NULL DEFAULT 0,
|
||||||
|
external_status enum('pending','queued','scanned','matches','error') NOT NULL DEFAULT 'pending',
|
||||||
|
internal_scanned_at datetime DEFAULT NULL,
|
||||||
|
external_scanned_at datetime DEFAULT NULL,
|
||||||
|
has_alert tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
is_dismissed tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
updated_at datetime NOT NULL,
|
||||||
|
PRIMARY KEY (site_id, attachment_id),
|
||||||
|
KEY external_status (external_status),
|
||||||
|
KEY has_alert (has_alert)
|
||||||
|
) {$charset_collate};"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the network mirror table. Used only during full network uninstall.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function drop_network_table(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table_name();
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all mirror rows for a given site.
|
||||||
|
*
|
||||||
|
* Called when a site resets all its plugin data (truncate_all).
|
||||||
|
*
|
||||||
|
* @param int $site_id WordPress blog ID.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function clear_site( int $site_id ): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$wpdb->delete( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
|
self::table_name(),
|
||||||
|
array( 'site_id' => $site_id ),
|
||||||
|
array( '%d' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs the mirror table for the currently active site.
|
||||||
|
*
|
||||||
|
* Reads mra_media_index, mra_external_results (for has_alert), and
|
||||||
|
* mra_dismissed_alerts (for is_dismissed) from the current site's tables,
|
||||||
|
* then replaces all mirror rows for this site.
|
||||||
|
*
|
||||||
|
* Must be called while switch_to_blog($site_id) is active.
|
||||||
|
*
|
||||||
|
* @param int $site_id WordPress blog ID (must match the currently active blog).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function sync_site( int $site_id ): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$site_url = get_site_url( $site_id );
|
||||||
|
$table = self::table_name();
|
||||||
|
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
"SELECT attachment_id, file_name, file_url, mime_type, file_size,
|
||||||
|
external_status, internal_scanned_at, external_scanned_at
|
||||||
|
FROM {$wpdb->prefix}mra_media_index",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
|
||||||
|
self::clear_site( $site_id );
|
||||||
|
|
||||||
|
if ( empty( $rows ) || ! is_array( $rows ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ids = array_map( 'intval', array_column( $rows, 'attachment_id' ) );
|
||||||
|
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
|
||||||
|
|
||||||
|
// Load top_domains per attachment to compute has_alert.
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||||
|
$domain_rows = $wpdb->get_results(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"SELECT attachment_id, top_domains
|
||||||
|
FROM {$wpdb->prefix}mra_external_results
|
||||||
|
WHERE attachment_id IN ({$placeholders})",
|
||||||
|
...$ids
|
||||||
|
),
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
|
||||||
|
|
||||||
|
// Build merged domain map: attachment_id → domain → count.
|
||||||
|
$domains_by_id = array();
|
||||||
|
if ( is_array( $domain_rows ) ) {
|
||||||
|
foreach ( $domain_rows as $dr ) {
|
||||||
|
if ( ! is_array( $dr ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$aid = is_numeric( $dr['attachment_id'] ?? null ) ? (int) $dr['attachment_id'] : 0;
|
||||||
|
$td = is_string( $dr['top_domains'] ?? null ) ? json_decode( $dr['top_domains'], true ) : null;
|
||||||
|
if ( $aid <= 0 || ! is_array( $td ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ( ! isset( $domains_by_id[ $aid ] ) ) {
|
||||||
|
$domains_by_id[ $aid ] = array();
|
||||||
|
}
|
||||||
|
foreach ( $td as $domain => $count ) {
|
||||||
|
if ( is_string( $domain ) ) {
|
||||||
|
$domains_by_id[ $aid ][ $domain ] = ( $domains_by_id[ $aid ][ $domain ] ?? 0 ) + ( is_numeric( $count ) ? (int) $count : 0 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load dismissed attachment IDs.
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
|
$dismissed_col = $wpdb->get_col( "SELECT attachment_id FROM {$wpdb->prefix}mra_dismissed_alerts" );
|
||||||
|
$dismissed_ids = array();
|
||||||
|
if ( is_array( $dismissed_col ) ) {
|
||||||
|
foreach ( $dismissed_col as $did ) {
|
||||||
|
if ( is_numeric( $did ) ) {
|
||||||
|
$dismissed_ids[ (int) $did ] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = current_time( 'mysql', true );
|
||||||
|
|
||||||
|
foreach ( $rows as $row ) {
|
||||||
|
if ( ! is_array( $row ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$aid = is_numeric( $row['attachment_id'] ?? null ) ? (int) $row['attachment_id'] : 0;
|
||||||
|
if ( $aid <= 0 ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$has_alert = HostnameFilter::has_alert_domains( $domains_by_id[ $aid ] ?? array() ) ? 1 : 0;
|
||||||
|
$is_dismissed = isset( $dismissed_ids[ $aid ] ) ? 1 : 0;
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$wpdb->query(
|
||||||
|
$wpdb->prepare(
|
||||||
|
"REPLACE INTO `{$table}`
|
||||||
|
(site_id, attachment_id, site_url, file_name, file_url, mime_type,
|
||||||
|
file_size, external_status, internal_scanned_at, external_scanned_at,
|
||||||
|
has_alert, is_dismissed, updated_at)
|
||||||
|
VALUES (%d, %d, %s, %s, %s, %s, %d, %s, %s, %s, %d, %d, %s)",
|
||||||
|
$site_id,
|
||||||
|
$aid,
|
||||||
|
$site_url,
|
||||||
|
is_string( $row['file_name'] ?? null ) ? $row['file_name'] : '',
|
||||||
|
is_string( $row['file_url'] ?? null ) ? $row['file_url'] : '',
|
||||||
|
is_string( $row['mime_type'] ?? null ) ? $row['mime_type'] : '',
|
||||||
|
is_numeric( $row['file_size'] ?? null ) ? (int) $row['file_size'] : 0,
|
||||||
|
is_string( $row['external_status'] ?? null ) ? $row['external_status'] : 'pending',
|
||||||
|
is_string( $row['internal_scanned_at'] ?? null ) && '' !== $row['internal_scanned_at'] ? $row['internal_scanned_at'] : null,
|
||||||
|
is_string( $row['external_scanned_at'] ?? null ) && '' !== $row['external_scanned_at'] ? $row['external_scanned_at'] : null,
|
||||||
|
$has_alert,
|
||||||
|
$is_dismissed,
|
||||||
|
$now
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the dismissal flag for a single attachment in the mirror.
|
||||||
|
*
|
||||||
|
* Lighter alternative to sync_site() when only dismissal status changes.
|
||||||
|
*
|
||||||
|
* @param int $site_id WordPress blog ID.
|
||||||
|
* @param int $attachment_id WordPress attachment ID.
|
||||||
|
* @param bool $is_dismissed Whether the alert is dismissed.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public static function update_dismissal_flag( int $site_id, int $attachment_id, bool $is_dismissed ): void {
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
$wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||||
|
self::table_name(),
|
||||||
|
array(
|
||||||
|
'is_dismissed' => $is_dismissed ? 1 : 0,
|
||||||
|
'updated_at' => current_time( 'mysql', true ),
|
||||||
|
),
|
||||||
|
array(
|
||||||
|
'site_id' => $site_id,
|
||||||
|
'attachment_id' => $attachment_id,
|
||||||
|
),
|
||||||
|
array( '%d', '%s' ),
|
||||||
|
array( '%d', '%d' )
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,6 +118,20 @@ class Plugin {
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function register_admin_pages(): void {
|
public function register_admin_pages(): void {
|
||||||
|
// In central mode, replace the full admin UI with a placeholder notice.
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
add_menu_page(
|
||||||
|
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||||
|
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||||
|
'edit_others_posts',
|
||||||
|
'robotstxt-mediaaudit',
|
||||||
|
array( $this, 'render_central_mode_notice' ),
|
||||||
|
'dashicons-camera',
|
||||||
|
11
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$suffix = add_menu_page(
|
$suffix = add_menu_page(
|
||||||
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||||
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
__( 'Media Audit', 'robotstxt-mediaaudit' ),
|
||||||
|
|
@ -233,8 +247,7 @@ class Plugin {
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
private function is_provider_configured( string $slug ): bool {
|
private function is_provider_configured( string $slug ): bool {
|
||||||
$raw = get_option( Settings::OPTION_NAME, array() );
|
$opts = Settings::get_effective_settings();
|
||||||
$opts = is_array( $raw ) ? $raw : array();
|
|
||||||
|
|
||||||
if ( 'picdefense' === $slug ) {
|
if ( 'picdefense' === $slug ) {
|
||||||
$uid = $opts['picdefense_user_id'] ?? '';
|
$uid = $opts['picdefense_user_id'] ?? '';
|
||||||
|
|
@ -245,6 +258,33 @@ class Plugin {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the central-mode placeholder shown on each site's admin pages.
|
||||||
|
*
|
||||||
|
* Replaces the full per-site admin UI when the network is operating in
|
||||||
|
* central mode, directing users to the network admin panel instead.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function render_central_mode_notice(): void {
|
||||||
|
$network_url = network_admin_url( 'admin.php?page=robotstxt-mediaaudit-network' );
|
||||||
|
?>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1><?php esc_html_e( 'Media Audit', 'robotstxt-mediaaudit' ); ?></h1>
|
||||||
|
<div class="notice notice-info" style="margin-top:1em;">
|
||||||
|
<p>
|
||||||
|
<?php esc_html_e( 'This plugin is managed at the network level. Media data for this site is collected and stored here, but viewed and managed from the Network Admin panel.', 'robotstxt-mediaaudit' ); ?>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="<?php echo esc_url( $network_url ); ?>" class="button button-primary">
|
||||||
|
<?php esc_html_e( 'Go to Network Media Audit →', 'robotstxt-mediaaudit' ); ?>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registers WP-CLI commands when running in CLI context.
|
* Registers WP-CLI commands when running in CLI context.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
5
includes/External/AbstractProvider.php
vendored
5
includes/External/AbstractProvider.php
vendored
|
|
@ -11,6 +11,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use MediaRightsAudit\Admin\Settings;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides rate-limited scanning via a transient-based per-minute counter.
|
* Provides rate-limited scanning via a transient-based per-minute counter.
|
||||||
*
|
*
|
||||||
|
|
@ -41,8 +43,7 @@ abstract class AbstractProvider {
|
||||||
* @return positive-int
|
* @return positive-int
|
||||||
*/
|
*/
|
||||||
public function rate_limit(): int {
|
public function rate_limit(): int {
|
||||||
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
|
$opts = Settings::get_effective_settings();
|
||||||
$opts = is_array( $raw ) ? $raw : array();
|
|
||||||
$val = $opts['rate_limit_per_minute'] ?? null;
|
$val = $opts['rate_limit_per_minute'] ?? null;
|
||||||
return is_numeric( $val ) ? max( 1, (int) $val ) : 10;
|
return is_numeric( $val ) ? max( 1, (int) $val ) : 10;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
includes/External/ExternalScanner.php
vendored
8
includes/External/ExternalScanner.php
vendored
|
|
@ -11,6 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use MediaRightsAudit\Admin\Settings;
|
||||||
use MediaRightsAudit\Core\Queue\Scheduler;
|
use MediaRightsAudit\Core\Queue\Scheduler;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -142,8 +143,7 @@ class ExternalScanner {
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public static function process_scheduled_batch(): void {
|
public static function process_scheduled_batch(): void {
|
||||||
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
|
$opts = Settings::get_effective_settings();
|
||||||
$opts = is_array( $raw ) ? $raw : array();
|
|
||||||
$bs_val = $opts['external_batch_size'] ?? null;
|
$bs_val = $opts['external_batch_size'] ?? null;
|
||||||
$batch_size = is_numeric( $bs_val ) ? max( 1, (int) $bs_val ) : 10;
|
$batch_size = is_numeric( $bs_val ) ? max( 1, (int) $bs_val ) : 10;
|
||||||
|
|
||||||
|
|
@ -151,6 +151,10 @@ class ExternalScanner {
|
||||||
|
|
||||||
delete_transient( 'mra_alert_count' );
|
delete_transient( 'mra_alert_count' );
|
||||||
|
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
\MediaRightsAudit\Core\NetworkDatabase::sync_site( get_current_blog_id() );
|
||||||
|
}
|
||||||
|
|
||||||
if ( self::get_pending_count() > 0 ) {
|
if ( self::get_pending_count() > 0 ) {
|
||||||
Scheduler::schedule_single( self::AS_HOOK );
|
Scheduler::schedule_single( self::AS_HOOK );
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
includes/External/GoogleVisionProvider.php
vendored
4
includes/External/GoogleVisionProvider.php
vendored
|
|
@ -126,8 +126,8 @@ class GoogleVisionProvider extends AbstractProvider {
|
||||||
* @return string Empty string if not configured.
|
* @return string Empty string if not configured.
|
||||||
*/
|
*/
|
||||||
private function get_api_key(): string {
|
private function get_api_key(): string {
|
||||||
$raw = get_option( Settings::OPTION_NAME, array() );
|
$opts = Settings::get_effective_settings();
|
||||||
$opts = is_array( $raw ) ? $raw : array();
|
|
||||||
$val = $opts['google_vision_api_key'] ?? null;
|
$val = $opts['google_vision_api_key'] ?? null;
|
||||||
return is_string( $val ) ? trim( $val ) : '';
|
return is_string( $val ) ? trim( $val ) : '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
includes/External/PicDefenseProvider.php
vendored
4
includes/External/PicDefenseProvider.php
vendored
|
|
@ -112,8 +112,8 @@ class PicDefenseProvider extends AbstractProvider {
|
||||||
* @return array{string, string} [user_id, api_key] — empty strings if not configured.
|
* @return array{string, string} [user_id, api_key] — empty strings if not configured.
|
||||||
*/
|
*/
|
||||||
private function get_credentials(): array {
|
private function get_credentials(): array {
|
||||||
$raw = get_option( Settings::OPTION_NAME, array() );
|
$opts = Settings::get_effective_settings();
|
||||||
$opts = is_array( $raw ) ? $raw : array();
|
|
||||||
$uid_val = $opts['picdefense_user_id'] ?? null;
|
$uid_val = $opts['picdefense_user_id'] ?? null;
|
||||||
$key_val = $opts['picdefense_api_key'] ?? null;
|
$key_val = $opts['picdefense_api_key'] ?? null;
|
||||||
$user_id = is_string( $uid_val ) ? trim( $uid_val ) : '';
|
$user_id = is_string( $uid_val ) ? trim( $uid_val ) : '';
|
||||||
|
|
|
||||||
4
includes/External/TinEyeProvider.php
vendored
4
includes/External/TinEyeProvider.php
vendored
|
|
@ -104,8 +104,8 @@ class TinEyeProvider extends AbstractProvider {
|
||||||
* @return string Empty string if not configured.
|
* @return string Empty string if not configured.
|
||||||
*/
|
*/
|
||||||
private function get_api_key(): string {
|
private function get_api_key(): string {
|
||||||
$raw = get_option( Settings::OPTION_NAME, array() );
|
$opts = Settings::get_effective_settings();
|
||||||
$opts = is_array( $raw ) ? $raw : array();
|
|
||||||
$val = $opts['tineye_api_key'] ?? null;
|
$val = $opts['tineye_api_key'] ?? null;
|
||||||
return is_string( $val ) ? trim( $val ) : '';
|
return is_string( $val ) ? trim( $val ) : '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@ class AttachmentIndexer {
|
||||||
public static function process_scheduled_batch(): void {
|
public static function process_scheduled_batch(): void {
|
||||||
self::index_batch();
|
self::index_batch();
|
||||||
|
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
\MediaRightsAudit\Core\NetworkDatabase::sync_site( get_current_blog_id() );
|
||||||
|
}
|
||||||
|
|
||||||
if ( self::get_pending_count() > 0 ) {
|
if ( self::get_pending_count() > 0 ) {
|
||||||
Scheduler::schedule_single( self::AS_HOOK );
|
Scheduler::schedule_single( self::AS_HOOK );
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -450,6 +450,10 @@ class UsageScanner {
|
||||||
public static function process_scheduled_batch(): void {
|
public static function process_scheduled_batch(): void {
|
||||||
self::scan_batch();
|
self::scan_batch();
|
||||||
|
|
||||||
|
if ( is_multisite() && 'central' === get_site_option( 'robotstxt_mediaaudit_network_mode', 'per_site' ) ) {
|
||||||
|
\MediaRightsAudit\Core\NetworkDatabase::sync_site( get_current_blog_id() );
|
||||||
|
}
|
||||||
|
|
||||||
if ( self::get_pending_count() > 0 ) {
|
if ( self::get_pending_count() > 0 ) {
|
||||||
Scheduler::schedule_single( self::AS_HOOK );
|
Scheduler::schedule_single( self::AS_HOOK );
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ Requires at least: 5.3
|
||||||
Tested up to: 7.0
|
Tested up to: 7.0
|
||||||
Requires PHP: 8.0
|
Requires PHP: 8.0
|
||||||
Requires Plugins: action-scheduler
|
Requires Plugins: action-scheduler
|
||||||
Stable tag: 1.7.1
|
Stable tag: 1.8.1
|
||||||
License: GPL-3.0-or-later
|
License: GPL-3.0-or-later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
License URI: https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@
|
||||||
* Plugin Name: Media Audit (by ROBOTSTXT)
|
* Plugin Name: Media Audit (by ROBOTSTXT)
|
||||||
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-mediaaudit
|
* Plugin URI: https://git.robotstxt.es/ROBOTSTXT/robotstxt-mediaaudit
|
||||||
* Description: Internal media library usage auditing and external reverse image search to detect potential copyright issues.
|
* Description: Internal media library usage auditing and external reverse image search to detect potential copyright issues.
|
||||||
* Version: 1.7.1
|
* Version: 1.8.1
|
||||||
* Requires at least: 5.3
|
* Requires at least: 5.3
|
||||||
* Tested up to: 7.0
|
* Tested up to: 7.0
|
||||||
* Requires PHP: 8.0
|
* Requires PHP: 8.0
|
||||||
* Requires Plugins: action-scheduler
|
* Requires Plugins: action-scheduler
|
||||||
|
* Network: true
|
||||||
* Author: ROBOTSTXT
|
* Author: ROBOTSTXT
|
||||||
* Author URI: https://www.robotstxt.es/
|
* Author URI: https://www.robotstxt.es/
|
||||||
* Contributors: javiercasares, robotstxt
|
* Contributors: javiercasares, robotstxt
|
||||||
|
|
@ -23,8 +24,8 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.7.1' );
|
define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.8.1' );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.2.0' );
|
define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.3.0' );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ );
|
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|
@ -33,19 +34,62 @@ require_once ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR . 'vendor/autoload.php';
|
||||||
|
|
||||||
use MediaRightsAudit\Core\Activator;
|
use MediaRightsAudit\Core\Activator;
|
||||||
use MediaRightsAudit\Core\Deactivator;
|
use MediaRightsAudit\Core\Deactivator;
|
||||||
|
use MediaRightsAudit\Core\NetworkActivator;
|
||||||
use MediaRightsAudit\Core\Plugin;
|
use MediaRightsAudit\Core\Plugin;
|
||||||
|
use MediaRightsAudit\Network\Plugin as NetworkPlugin;
|
||||||
register_activation_hook( __FILE__, array( Activator::class, 'activate' ) );
|
|
||||||
register_deactivation_hook( __FILE__, array( Deactivator::class, 'deactivate' ) );
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstraps the plugin.
|
* Handles activation for both single-site and network-wide contexts.
|
||||||
|
*
|
||||||
|
* @param bool $network_wide True when activated network-wide in Multisite.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function robotstxt_mediaaudit_activate( bool $network_wide ): void {
|
||||||
|
if ( is_multisite() && $network_wide ) {
|
||||||
|
NetworkActivator::network_activate();
|
||||||
|
} else {
|
||||||
|
Activator::activate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles deactivation for both single-site and network-wide contexts.
|
||||||
|
*
|
||||||
|
* @param bool $network_wide True when deactivated network-wide in Multisite.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
function robotstxt_mediaaudit_deactivate( bool $network_wide ): void {
|
||||||
|
if ( is_multisite() && $network_wide ) {
|
||||||
|
NetworkActivator::network_deactivate();
|
||||||
|
} else {
|
||||||
|
Deactivator::deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
register_activation_hook( __FILE__, 'robotstxt_mediaaudit_activate' );
|
||||||
|
register_deactivation_hook( __FILE__, 'robotstxt_mediaaudit_deactivate' );
|
||||||
|
|
||||||
|
// Provision tables for newly created sites when the plugin is network-active.
|
||||||
|
add_action( 'wp_initialize_site', array( NetworkActivator::class, 'on_new_site' ) );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstraps the site-level plugin.
|
||||||
|
*
|
||||||
|
* In Multisite central mode, registers a placeholder notice instead of the full admin UI
|
||||||
|
* so all management happens from the network admin panel.
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
function robotstxt_mediaaudit_run(): void {
|
function robotstxt_mediaaudit_run(): void {
|
||||||
$plugin = new Plugin();
|
$plugin = new Plugin();
|
||||||
$plugin->run();
|
$plugin->run();
|
||||||
|
|
||||||
|
if ( is_multisite() ) {
|
||||||
|
$network_plugin = new NetworkPlugin();
|
||||||
|
$network_plugin->run();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add_action( 'plugins_loaded', 'robotstxt_mediaaudit_run' );
|
add_action( 'plugins_loaded', 'robotstxt_mediaaudit_run' );
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Fired when the plugin is uninstalled.
|
* Fired when the plugin is uninstalled.
|
||||||
*
|
*
|
||||||
* Drops all plugin data only if the user has explicitly opted in via Settings.
|
* Drops all plugin data only if the user has explicitly opted in via Settings.
|
||||||
* By default, data is preserved.
|
* By default, data is preserved. On Multisite, iterates all sites.
|
||||||
*
|
*
|
||||||
* @package MediaRightsAudit
|
* @package MediaRightsAudit
|
||||||
*/
|
*/
|
||||||
|
|
@ -12,22 +12,79 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
|
|
||||||
$settings = is_array( $raw ) ? $raw : array();
|
|
||||||
$delete = ! empty( $settings['delete_on_uninstall'] );
|
|
||||||
|
|
||||||
if ( ! $delete ) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
// Drop tables in reverse dependency order.
|
if ( is_multisite() ) {
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_dismissed_alerts`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
// In Multisite, read the delete preference from the network settings option.
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_provider_status`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
$net_raw = get_site_option( 'robotstxt_mediaaudit_network_settings', array() );
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
$net_opts = is_array( $net_raw ) ? $net_raw : array();
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
$delete = ! empty( $net_opts['delete_on_uninstall'] );
|
||||||
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
|
||||||
|
|
||||||
delete_option( 'robotstxt_mediaaudit_db_version' );
|
// Also check any site that may have its own setting.
|
||||||
delete_option( 'robotstxt_mediaaudit_settings' );
|
if ( ! $delete ) {
|
||||||
|
// Check the main site's per-site setting as a fallback.
|
||||||
|
switch_to_blog( get_main_site_id() );
|
||||||
|
$site_raw = get_option( 'robotstxt_mediaaudit_settings', array() );
|
||||||
|
$site_opts = is_array( $site_raw ) ? $site_raw : array();
|
||||||
|
$delete = ! empty( $site_opts['delete_on_uninstall'] );
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! $delete ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop per-site tables for every site.
|
||||||
|
$sites = get_sites(
|
||||||
|
array(
|
||||||
|
'fields' => 'ids',
|
||||||
|
'number' => 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if ( is_array( $sites ) ) {
|
||||||
|
foreach ( $sites as $site_id ) {
|
||||||
|
switch_to_blog( (int) $site_id );
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_dismissed_alerts`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_provider_status`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" );
|
||||||
|
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||||
|
|
||||||
|
delete_option( 'robotstxt_mediaaudit_db_version' );
|
||||||
|
delete_option( 'robotstxt_mediaaudit_settings' );
|
||||||
|
|
||||||
|
restore_current_blog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the network mirror table (lives in main site DB, uses base_prefix).
|
||||||
|
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->base_prefix}mra_network_index`" );
|
||||||
|
|
||||||
|
delete_site_option( 'robotstxt_mediaaudit_network_mode' );
|
||||||
|
delete_site_option( 'robotstxt_mediaaudit_network_settings' );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Single-site uninstall.
|
||||||
|
$raw = get_option( 'robotstxt_mediaaudit_settings', array() );
|
||||||
|
$settings = is_array( $raw ) ? $raw : array();
|
||||||
|
$delete = ! empty( $settings['delete_on_uninstall'] );
|
||||||
|
|
||||||
|
if ( ! $delete ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// phpcs:disable WordPress.DB.DirectDatabaseQuery
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_dismissed_alerts`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_provider_status`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" );
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" );
|
||||||
|
// phpcs:enable WordPress.DB.DirectDatabaseQuery
|
||||||
|
|
||||||
|
delete_option( 'robotstxt_mediaaudit_db_version' );
|
||||||
|
delete_option( 'robotstxt_mediaaudit_settings' );
|
||||||
|
}
|
||||||
|
|
|
||||||
8
vendor/composer/autoload_classmap.php
vendored
8
vendor/composer/autoload_classmap.php
vendored
|
|
@ -17,6 +17,8 @@ return array(
|
||||||
'MediaRightsAudit\\Core\\Activator' => $baseDir . '/includes/Core/Activator.php',
|
'MediaRightsAudit\\Core\\Activator' => $baseDir . '/includes/Core/Activator.php',
|
||||||
'MediaRightsAudit\\Core\\Database' => $baseDir . '/includes/Core/Database.php',
|
'MediaRightsAudit\\Core\\Database' => $baseDir . '/includes/Core/Database.php',
|
||||||
'MediaRightsAudit\\Core\\Deactivator' => $baseDir . '/includes/Core/Deactivator.php',
|
'MediaRightsAudit\\Core\\Deactivator' => $baseDir . '/includes/Core/Deactivator.php',
|
||||||
|
'MediaRightsAudit\\Core\\NetworkActivator' => $baseDir . '/includes/Core/NetworkActivator.php',
|
||||||
|
'MediaRightsAudit\\Core\\NetworkDatabase' => $baseDir . '/includes/Core/NetworkDatabase.php',
|
||||||
'MediaRightsAudit\\Core\\Plugin' => $baseDir . '/includes/Core/Plugin.php',
|
'MediaRightsAudit\\Core\\Plugin' => $baseDir . '/includes/Core/Plugin.php',
|
||||||
'MediaRightsAudit\\Core\\Queue\\Scheduler' => $baseDir . '/includes/Core/Queue/Scheduler.php',
|
'MediaRightsAudit\\Core\\Queue\\Scheduler' => $baseDir . '/includes/Core/Queue/Scheduler.php',
|
||||||
'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php',
|
'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php',
|
||||||
|
|
@ -29,6 +31,12 @@ return array(
|
||||||
'MediaRightsAudit\\External\\TinEyeProvider' => $baseDir . '/includes/External/TinEyeProvider.php',
|
'MediaRightsAudit\\External\\TinEyeProvider' => $baseDir . '/includes/External/TinEyeProvider.php',
|
||||||
'MediaRightsAudit\\Internal\\AttachmentIndexer' => $baseDir . '/includes/Internal/AttachmentIndexer.php',
|
'MediaRightsAudit\\Internal\\AttachmentIndexer' => $baseDir . '/includes/Internal/AttachmentIndexer.php',
|
||||||
'MediaRightsAudit\\Internal\\UsageScanner' => $baseDir . '/includes/Internal/UsageScanner.php',
|
'MediaRightsAudit\\Internal\\UsageScanner' => $baseDir . '/includes/Internal/UsageScanner.php',
|
||||||
|
'MediaRightsAudit\\Network\\AlertsPage' => $baseDir . '/includes/Network/AlertsPage.php',
|
||||||
|
'MediaRightsAudit\\Network\\AuditPage' => $baseDir . '/includes/Network/AuditPage.php',
|
||||||
|
'MediaRightsAudit\\Network\\NetworkListTable' => $baseDir . '/includes/Network/NetworkListTable.php',
|
||||||
|
'MediaRightsAudit\\Network\\Plugin' => $baseDir . '/includes/Network/Plugin.php',
|
||||||
|
'MediaRightsAudit\\Network\\Settings' => $baseDir . '/includes/Network/Settings.php',
|
||||||
|
'MediaRightsAudit\\Network\\ToolsPage' => $baseDir . '/includes/Network/ToolsPage.php',
|
||||||
'MediaRightsAudit\\Privacy\\DataEraser' => $baseDir . '/includes/Privacy/DataEraser.php',
|
'MediaRightsAudit\\Privacy\\DataEraser' => $baseDir . '/includes/Privacy/DataEraser.php',
|
||||||
'MediaRightsAudit\\Privacy\\DataExporter' => $baseDir . '/includes/Privacy/DataExporter.php',
|
'MediaRightsAudit\\Privacy\\DataExporter' => $baseDir . '/includes/Privacy/DataExporter.php',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
8
vendor/composer/autoload_static.php
vendored
8
vendor/composer/autoload_static.php
vendored
|
|
@ -32,6 +32,8 @@ class ComposerStaticInit149e6f8eeaef8d46113f09e1e8d4e757
|
||||||
'MediaRightsAudit\\Core\\Activator' => __DIR__ . '/../..' . '/includes/Core/Activator.php',
|
'MediaRightsAudit\\Core\\Activator' => __DIR__ . '/../..' . '/includes/Core/Activator.php',
|
||||||
'MediaRightsAudit\\Core\\Database' => __DIR__ . '/../..' . '/includes/Core/Database.php',
|
'MediaRightsAudit\\Core\\Database' => __DIR__ . '/../..' . '/includes/Core/Database.php',
|
||||||
'MediaRightsAudit\\Core\\Deactivator' => __DIR__ . '/../..' . '/includes/Core/Deactivator.php',
|
'MediaRightsAudit\\Core\\Deactivator' => __DIR__ . '/../..' . '/includes/Core/Deactivator.php',
|
||||||
|
'MediaRightsAudit\\Core\\NetworkActivator' => __DIR__ . '/../..' . '/includes/Core/NetworkActivator.php',
|
||||||
|
'MediaRightsAudit\\Core\\NetworkDatabase' => __DIR__ . '/../..' . '/includes/Core/NetworkDatabase.php',
|
||||||
'MediaRightsAudit\\Core\\Plugin' => __DIR__ . '/../..' . '/includes/Core/Plugin.php',
|
'MediaRightsAudit\\Core\\Plugin' => __DIR__ . '/../..' . '/includes/Core/Plugin.php',
|
||||||
'MediaRightsAudit\\Core\\Queue\\Scheduler' => __DIR__ . '/../..' . '/includes/Core/Queue/Scheduler.php',
|
'MediaRightsAudit\\Core\\Queue\\Scheduler' => __DIR__ . '/../..' . '/includes/Core/Queue/Scheduler.php',
|
||||||
'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php',
|
'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php',
|
||||||
|
|
@ -44,6 +46,12 @@ class ComposerStaticInit149e6f8eeaef8d46113f09e1e8d4e757
|
||||||
'MediaRightsAudit\\External\\TinEyeProvider' => __DIR__ . '/../..' . '/includes/External/TinEyeProvider.php',
|
'MediaRightsAudit\\External\\TinEyeProvider' => __DIR__ . '/../..' . '/includes/External/TinEyeProvider.php',
|
||||||
'MediaRightsAudit\\Internal\\AttachmentIndexer' => __DIR__ . '/../..' . '/includes/Internal/AttachmentIndexer.php',
|
'MediaRightsAudit\\Internal\\AttachmentIndexer' => __DIR__ . '/../..' . '/includes/Internal/AttachmentIndexer.php',
|
||||||
'MediaRightsAudit\\Internal\\UsageScanner' => __DIR__ . '/../..' . '/includes/Internal/UsageScanner.php',
|
'MediaRightsAudit\\Internal\\UsageScanner' => __DIR__ . '/../..' . '/includes/Internal/UsageScanner.php',
|
||||||
|
'MediaRightsAudit\\Network\\AlertsPage' => __DIR__ . '/../..' . '/includes/Network/AlertsPage.php',
|
||||||
|
'MediaRightsAudit\\Network\\AuditPage' => __DIR__ . '/../..' . '/includes/Network/AuditPage.php',
|
||||||
|
'MediaRightsAudit\\Network\\NetworkListTable' => __DIR__ . '/../..' . '/includes/Network/NetworkListTable.php',
|
||||||
|
'MediaRightsAudit\\Network\\Plugin' => __DIR__ . '/../..' . '/includes/Network/Plugin.php',
|
||||||
|
'MediaRightsAudit\\Network\\Settings' => __DIR__ . '/../..' . '/includes/Network/Settings.php',
|
||||||
|
'MediaRightsAudit\\Network\\ToolsPage' => __DIR__ . '/../..' . '/includes/Network/ToolsPage.php',
|
||||||
'MediaRightsAudit\\Privacy\\DataEraser' => __DIR__ . '/../..' . '/includes/Privacy/DataEraser.php',
|
'MediaRightsAudit\\Privacy\\DataEraser' => __DIR__ . '/../..' . '/includes/Privacy/DataEraser.php',
|
||||||
'MediaRightsAudit\\Privacy\\DataExporter' => __DIR__ . '/../..' . '/includes/Privacy/DataExporter.php',
|
'MediaRightsAudit\\Privacy\\DataExporter' => __DIR__ . '/../..' . '/includes/Privacy/DataExporter.php',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue