diff --git a/assets/js/media-audit-admin.js b/assets/js/media-audit-admin.js index 696029f..28593df 100644 --- a/assets/js/media-audit-admin.js +++ b/assets/js/media-audit-admin.js @@ -1,4 +1,4 @@ -/* global mraAdmin, mraTools */ +/* global mraAdmin, mraTools, mraAlerts, mraNetworkTools */ ( function ( $ ) { 'use strict'; @@ -163,5 +163,39 @@ $row.prop( 'hidden', true ); $( '[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 ) ); diff --git a/changelog.txt b/changelog.txt index d120f9b..f0a89d7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,32 @@ == 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 = _Release date: 2026-06-02_ diff --git a/includes/Admin/AlertsPage.php b/includes/Admin/AlertsPage.php index 31751f7..ffe9e58 100644 --- a/includes/Admin/AlertsPage.php +++ b/includes/Admin/AlertsPage.php @@ -137,6 +137,10 @@ class AlertsPage { delete_transient( 'mra_alert_count' ); 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' ); 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 ); + } } // ------------------------------------------------------------------------- diff --git a/includes/Admin/Settings.php b/includes/Admin/Settings.php index c3ff740..a395b77 100644 --- a/includes/Admin/Settings.php +++ b/includes/Admin/Settings.php @@ -222,6 +222,40 @@ class Settings { 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 + */ + 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 $input Raw POST input (must include '_tab'). + * @param array $current Existing option values to merge against. + * + * @return array + */ + 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. * @@ -367,7 +401,19 @@ class Settings { public function sanitize( $input ): array { $raw = get_option( self::OPTION_NAME, array() ); $current = is_array( $raw ) ? $raw : array(); - $output = $current; + 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 $current Existing values to merge against. + * + * @return array + */ + private function sanitize_with_base( $input, array $current ): array { + $output = $current; if ( ! is_array( $input ) ) { return $output; @@ -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 - * (*.example.com). A plain hostname implicitly covers all its subdomains - * at match time. + * @param mixed $raw Raw textarea value. + * + * @return list + */ + 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. * diff --git a/includes/Admin/ToolsPage.php b/includes/Admin/ToolsPage.php index 32634f2..ef70404 100644 --- a/includes/Admin/ToolsPage.php +++ b/includes/Admin/ToolsPage.php @@ -11,6 +11,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } +use MediaRightsAudit\Admin\Settings; use MediaRightsAudit\Core\Database; use MediaRightsAudit\Core\Queue\Scheduler; use MediaRightsAudit\External\ExternalScanner; @@ -132,8 +133,7 @@ class ToolsPage { case 'external': ExternalScanner::queue_all_pending(); - $raw_opts = get_option( 'robotstxt_mediaaudit_settings', array() ); - $opts_arr = is_array( $raw_opts ) ? $raw_opts : array(); + $opts_arr = Settings::get_effective_settings(); $bs_setting = $opts_arr['external_batch_size'] ?? null; $ajax_bs = is_numeric( $bs_setting ) ? max( 1, (int) $bs_setting ) : 10; ExternalScanner::scan_batch( $ajax_bs ); diff --git a/includes/Core/Database.php b/includes/Core/Database.php index dd6a12a..917ee94 100644 --- a/includes/Core/Database.php +++ b/includes/Core/Database.php @@ -11,6 +11,8 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } +use MediaRightsAudit\Core\NetworkDatabase; + /** * Manages all custom table creation and schema migrations. * @@ -29,6 +31,7 @@ class Database { self::migration_101(); self::migration_110(); self::migration_120(); + self::migration_130(); } /** @@ -57,6 +60,10 @@ class Database { if ( version_compare( $current, '1.2.0', '<' ) ) { self::migration_120(); } + + if ( version_compare( $current, '1.3.0', '<' ) ) { + self::migration_130(); + } } /** @@ -92,6 +99,10 @@ class Database { // phpcs:enable WordPress.DB.DirectDatabaseQuery 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};" ); } + + /** + * 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(); + } + } } diff --git a/includes/Core/NetworkActivator.php b/includes/Core/NetworkActivator.php new file mode 100644 index 0000000..d310b58 --- /dev/null +++ b/includes/Core/NetworkActivator.php @@ -0,0 +1,114 @@ +blog_id ); + Activator::activate(); + restore_current_blog(); + } + + /** + * Returns an array of active site IDs on this network. + * + * @return array + */ + 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(); + } +} diff --git a/includes/Core/NetworkDatabase.php b/includes/Core/NetworkDatabase.php new file mode 100644 index 0000000..ae274ed --- /dev/null +++ b/includes/Core/NetworkDatabase.php @@ -0,0 +1,261 @@ +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' ) + ); + } +} diff --git a/includes/Core/Plugin.php b/includes/Core/Plugin.php index 655cba8..2a99479 100644 --- a/includes/Core/Plugin.php +++ b/includes/Core/Plugin.php @@ -118,6 +118,20 @@ class Plugin { * @return 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( __( 'Media Audit', 'robotstxt-mediaaudit' ), __( 'Media Audit', 'robotstxt-mediaaudit' ), @@ -233,8 +247,7 @@ class Plugin { * @return bool */ private function is_provider_configured( string $slug ): bool { - $raw = get_option( Settings::OPTION_NAME, array() ); - $opts = is_array( $raw ) ? $raw : array(); + $opts = Settings::get_effective_settings(); if ( 'picdefense' === $slug ) { $uid = $opts['picdefense_user_id'] ?? ''; @@ -245,6 +258,33 @@ class Plugin { 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' ); + ?> +
+

+
+

+ +

+

+ + + +

+
+
+ 0 ) { Scheduler::schedule_single( self::AS_HOOK ); } diff --git a/includes/External/GoogleVisionProvider.php b/includes/External/GoogleVisionProvider.php index 67204ae..48d8ff0 100644 --- a/includes/External/GoogleVisionProvider.php +++ b/includes/External/GoogleVisionProvider.php @@ -126,9 +126,9 @@ class GoogleVisionProvider extends AbstractProvider { * @return string Empty string if not configured. */ private function get_api_key(): string { - $raw = get_option( Settings::OPTION_NAME, array() ); - $opts = is_array( $raw ) ? $raw : array(); - $val = $opts['google_vision_api_key'] ?? null; + $opts = Settings::get_effective_settings(); + + $val = $opts['google_vision_api_key'] ?? null; return is_string( $val ) ? trim( $val ) : ''; } diff --git a/includes/External/PicDefenseProvider.php b/includes/External/PicDefenseProvider.php index 36eb1c3..e46c31c 100644 --- a/includes/External/PicDefenseProvider.php +++ b/includes/External/PicDefenseProvider.php @@ -112,8 +112,8 @@ class PicDefenseProvider extends AbstractProvider { * @return array{string, string} [user_id, api_key] — empty strings if not configured. */ private function get_credentials(): array { - $raw = get_option( Settings::OPTION_NAME, array() ); - $opts = is_array( $raw ) ? $raw : array(); + $opts = Settings::get_effective_settings(); + $uid_val = $opts['picdefense_user_id'] ?? null; $key_val = $opts['picdefense_api_key'] ?? null; $user_id = is_string( $uid_val ) ? trim( $uid_val ) : ''; diff --git a/includes/External/TinEyeProvider.php b/includes/External/TinEyeProvider.php index cc0b06e..95d6b4f 100644 --- a/includes/External/TinEyeProvider.php +++ b/includes/External/TinEyeProvider.php @@ -104,9 +104,9 @@ class TinEyeProvider extends AbstractProvider { * @return string Empty string if not configured. */ private function get_api_key(): string { - $raw = get_option( Settings::OPTION_NAME, array() ); - $opts = is_array( $raw ) ? $raw : array(); - $val = $opts['tineye_api_key'] ?? null; + $opts = Settings::get_effective_settings(); + + $val = $opts['tineye_api_key'] ?? null; return is_string( $val ) ? trim( $val ) : ''; } diff --git a/includes/Internal/AttachmentIndexer.php b/includes/Internal/AttachmentIndexer.php index 7b6cec4..b7825cc 100644 --- a/includes/Internal/AttachmentIndexer.php +++ b/includes/Internal/AttachmentIndexer.php @@ -159,6 +159,10 @@ class AttachmentIndexer { public static function process_scheduled_batch(): void { 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 ) { Scheduler::schedule_single( self::AS_HOOK ); } else { diff --git a/includes/Internal/UsageScanner.php b/includes/Internal/UsageScanner.php index 616686e..9539f37 100644 --- a/includes/Internal/UsageScanner.php +++ b/includes/Internal/UsageScanner.php @@ -450,6 +450,10 @@ class UsageScanner { public static function process_scheduled_batch(): void { 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 ) { Scheduler::schedule_single( self::AS_HOOK ); } diff --git a/includes/Network/AlertsPage.php b/includes/Network/AlertsPage.php new file mode 100644 index 0000000..e5198ec --- /dev/null +++ b/includes/Network/AlertsPage.php @@ -0,0 +1,73 @@ +prepare_items(); + + $total_alert = $this->get_active_alert_count(); + ?> +
+

+ + 0 ) : ?> + + +

+ +

+ +

+ +
+ + display(); ?> +
+
+ get_var( "SELECT COUNT(*) FROM `{$table}` WHERE has_alert = 1 AND is_dismissed = 0" ); + } +} diff --git a/includes/Network/AuditPage.php b/includes/Network/AuditPage.php new file mode 100644 index 0000000..b00bf26 --- /dev/null +++ b/includes/Network/AuditPage.php @@ -0,0 +1,102 @@ +prepare_items(); + + $stats = $this->get_network_stats(); + ?> +
+

+ +
+ 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' ), '' ); + ?> +
+ +
+ + display(); ?> +
+
+ '; + echo '' . esc_html( number_format_i18n( $value ) ) . ''; + echo '' . esc_html( $label ) . ''; + if ( '' !== $sub ) { + echo '' . esc_html( $sub ) . ''; + } + echo ''; + } + + /** + * 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, + ); + } +} diff --git a/includes/Network/NetworkListTable.php b/includes/Network/NetworkListTable.php new file mode 100644 index 0000000..8e93f84 --- /dev/null +++ b/includes/Network/NetworkListTable.php @@ -0,0 +1,304 @@ + 'mra-network-attachment', + 'plural' => 'mra-network-attachments', + 'ajax' => false, + ) + ); + $this->alerts_only = $alerts_only; + } + + /** + * Returns the column definitions. + * + * @return array + */ + public function get_columns(): array { + return array( + 'cb' => '', + '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> + */ + 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 + */ + 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 $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( + '', + $site_id, + $aid + ); + } + + /** + * Renders the Site column. + * + * @param array $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( + '%s
%s', + 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 $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( + '%s', + esc_url( $detail_url ), + esc_html( $filename ) + ); + } + + /** + * Renders the External Status column. + * + * @param array $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 '' . esc_html( ucfirst( $status ) ) . ''; + } + + /** + * Renders the Alert column. + * + * @param array $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 '' . esc_html__( 'Dismissed', 'robotstxt-mediaaudit' ) . ''; + } + if ( $has_alert ) { + return '' . esc_html__( 'Alert', 'robotstxt-mediaaudit' ) . ''; + } + return '—'; + } + + /** + * Renders the Last Scanned column. + * + * @param array $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 $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' ); + } +} diff --git a/includes/Network/Plugin.php b/includes/Network/Plugin.php new file mode 100644 index 0000000..c6a60bc --- /dev/null +++ b/includes/Network/Plugin.php @@ -0,0 +1,148 @@ +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' ) + ); + } +} diff --git a/includes/Network/Settings.php b/includes/Network/Settings.php new file mode 100644 index 0000000..fd3ee97 --- /dev/null +++ b/includes/Network/Settings.php @@ -0,0 +1,382 @@ + + */ + private const VALID_MODES = array( 'per_site', 'central' ); + + /** + * Valid settings tabs (reuses same tab structure as per-site Settings). + * + * @var list + */ + 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 + ?> +
+

+ + +

+ + + + +
+ + + + + render_mode_tab( $mode ); ?> + + + render_api_tab( $opts ); ?> + + + render_filters_tab( $opts ); ?> + + + render_external_tab( $opts ); ?> + + + +
+
+ +

+

+ +

+ + + + + + + $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'] : ''; + ?> +

+ + + + + + + + + + + + + + + + + + + $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(); + ?> +

+

+

+ + +

+

+ + $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; + ?> + + + + + + + + + + + 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(); + ?> +
+

+ + render_notice( $notice ); ?> + +

+ render_site_status_table( $status ); ?> + +

+ render_network_operations(); ?> +
+ get_network_status(); + ?> +
+

+

+ +

+ render_site_status_table( $status ); ?> +
+ > $status Per-site status data keyed by site_id. + * + * @return void + */ + private function render_site_status_table( array $status ): void { + ?> + + + + + + + + + + + + + $s ) : ?> + + + + + + + + + + + +
+
+ + + +
✓' : ''; ?> + + + + + +
+

+ + + + +

+ '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', + ), + ); + ?> +
+ +
+

+

+
+ + + +
+
+ +
+ $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']; + ?> +
+

+ +

+
+ > + */ + 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; + } +} diff --git a/readme.txt b/readme.txt index 51499af..2754e45 100644 --- a/readme.txt +++ b/readme.txt @@ -5,7 +5,7 @@ Requires at least: 5.3 Tested up to: 7.0 Requires PHP: 8.0 Requires Plugins: action-scheduler -Stable tag: 1.7.1 +Stable tag: 1.8.1 License: GPL-3.0-or-later License URI: https://www.gnu.org/licenses/gpl-3.0.txt diff --git a/robotstxt-mediaaudit.php b/robotstxt-mediaaudit.php index d95337c..4e705f7 100644 --- a/robotstxt-mediaaudit.php +++ b/robotstxt-mediaaudit.php @@ -3,11 +3,12 @@ * Plugin Name: Media Audit (by ROBOTSTXT) * 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. - * Version: 1.7.1 + * Version: 1.8.1 * Requires at least: 5.3 * Tested up to: 7.0 * Requires PHP: 8.0 * Requires Plugins: action-scheduler + * Network: true * Author: ROBOTSTXT * Author URI: https://www.robotstxt.es/ * Contributors: javiercasares, robotstxt @@ -23,8 +24,8 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.7.1' ); -define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.2.0' ); +define( 'ROBOTSTXT_MEDIAAUDIT_VERSION', '1.8.1' ); +define( 'ROBOTSTXT_MEDIAAUDIT_DB_VERSION', '1.3.0' ); define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_FILE', __FILE__ ); define( 'ROBOTSTXT_MEDIAAUDIT_PLUGIN_DIR', plugin_dir_path( __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\Deactivator; +use MediaRightsAudit\Core\NetworkActivator; use MediaRightsAudit\Core\Plugin; - -register_activation_hook( __FILE__, array( Activator::class, 'activate' ) ); -register_deactivation_hook( __FILE__, array( Deactivator::class, 'deactivate' ) ); +use MediaRightsAudit\Network\Plugin as NetworkPlugin; /** - * 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 */ function robotstxt_mediaaudit_run(): void { $plugin = new Plugin(); $plugin->run(); + + if ( is_multisite() ) { + $network_plugin = new NetworkPlugin(); + $network_plugin->run(); + } } add_action( 'plugins_loaded', 'robotstxt_mediaaudit_run' ); diff --git a/uninstall.php b/uninstall.php index 3c33384..f64ce56 100644 --- a/uninstall.php +++ b/uninstall.php @@ -3,7 +3,7 @@ * Fired when the plugin is uninstalled. * * 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 */ @@ -12,22 +12,79 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { 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; -// Drop tables in reverse dependency order. -$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_dismissed_alerts`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_provider_status`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_external_results`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_usage`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -$wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->prefix}mra_media_index`" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery +if ( is_multisite() ) { + // In Multisite, read the delete preference from the network settings option. + $net_raw = get_site_option( 'robotstxt_mediaaudit_network_settings', array() ); + $net_opts = is_array( $net_raw ) ? $net_raw : array(); + $delete = ! empty( $net_opts['delete_on_uninstall'] ); -delete_option( 'robotstxt_mediaaudit_db_version' ); -delete_option( 'robotstxt_mediaaudit_settings' ); + // Also check any site that may have its own setting. + 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' ); +} diff --git a/vendor/composer/autoload_classmap.php b/vendor/composer/autoload_classmap.php index a1c1dfa..11b411a 100644 --- a/vendor/composer/autoload_classmap.php +++ b/vendor/composer/autoload_classmap.php @@ -17,6 +17,8 @@ return array( 'MediaRightsAudit\\Core\\Activator' => $baseDir . '/includes/Core/Activator.php', 'MediaRightsAudit\\Core\\Database' => $baseDir . '/includes/Core/Database.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\\Queue\\Scheduler' => $baseDir . '/includes/Core/Queue/Scheduler.php', 'MediaRightsAudit\\External\\AbstractProvider' => $baseDir . '/includes/External/AbstractProvider.php', @@ -29,6 +31,12 @@ return array( 'MediaRightsAudit\\External\\TinEyeProvider' => $baseDir . '/includes/External/TinEyeProvider.php', 'MediaRightsAudit\\Internal\\AttachmentIndexer' => $baseDir . '/includes/Internal/AttachmentIndexer.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\\DataExporter' => $baseDir . '/includes/Privacy/DataExporter.php', ); diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php index 700728e..aa32c51 100644 --- a/vendor/composer/autoload_static.php +++ b/vendor/composer/autoload_static.php @@ -32,6 +32,8 @@ class ComposerStaticInit149e6f8eeaef8d46113f09e1e8d4e757 'MediaRightsAudit\\Core\\Activator' => __DIR__ . '/../..' . '/includes/Core/Activator.php', 'MediaRightsAudit\\Core\\Database' => __DIR__ . '/../..' . '/includes/Core/Database.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\\Queue\\Scheduler' => __DIR__ . '/../..' . '/includes/Core/Queue/Scheduler.php', 'MediaRightsAudit\\External\\AbstractProvider' => __DIR__ . '/../..' . '/includes/External/AbstractProvider.php', @@ -44,6 +46,12 @@ class ComposerStaticInit149e6f8eeaef8d46113f09e1e8d4e757 'MediaRightsAudit\\External\\TinEyeProvider' => __DIR__ . '/../..' . '/includes/External/TinEyeProvider.php', 'MediaRightsAudit\\Internal\\AttachmentIndexer' => __DIR__ . '/../..' . '/includes/Internal/AttachmentIndexer.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\\DataExporter' => __DIR__ . '/../..' . '/includes/Privacy/DataExporter.php', );