robotstxt-mediaaudit/includes/Network/Settings.php
2026-08-17 18:51:45 +00:00

384 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 \MediaRightsAudit\Admin\ManagerNotice::render_settings_notice(); ?>
<?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
}
}