This commit is contained in:
Javier Casares 2026-06-08 18:01:39 +00:00
commit b6a55a7f04
27 changed files with 2221 additions and 45 deletions

View 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 (1100). 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 (160). Default: 10.', 'robotstxt-mediaaudit' ); ?></p>
</td>
</tr>
</table>
<?php
}
}