v1.8.1
This commit is contained in:
parent
7b7fff5246
commit
b6a55a7f04
27 changed files with 2221 additions and 45 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
*/
|
||||
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' );
|
||||
?>
|
||||
<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.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue