This commit is contained in:
Javier Casares 2026-06-03 06:29:56 +00:00
commit 98b1cf0f3b
17 changed files with 2968 additions and 1313 deletions

View file

@ -8,9 +8,11 @@
namespace MediaRightsAudit\Admin;
/**
* Registers and renders the plugin Settings page.
* Registers and renders the plugin Settings page with tab navigation.
*
* Settings are stored in a single option array: robotstxt_mediaaudit_settings.
* All tabs share a single option (robotstxt_mediaaudit_settings). A hidden
* _tab sentinel in each form submission tells sanitize() which fields to
* process, so saving one tab never clears another tab's data.
*/
class Settings {
@ -24,6 +26,198 @@ class Settings {
*/
const OPTION_NAME = 'robotstxt_mediaaudit_settings';
/**
* Default alert hostnames seeded on first activation.
*
* @var list<string>
*/
private const DEFAULT_FILTER_INCLUDE = array(
'123rf.com',
'afp.com',
'alamy.com',
'arcangel.com',
'artgrid.io',
'audioblocks.com',
'auroraphotos.com',
'backgridusa.com',
'bigstockphoto.com',
'blendimages.com',
'bridgemanimages.com',
'canstockphoto.com',
'cavanimages.com',
'creativemarket.com',
'crestock.com',
'cultura-rm.com',
'depositphotos.com',
'designbundles.net',
'diomedia.com',
'dreamstime.com',
'elements.envato.com',
'epa.eu',
'filmsupply.com',
'flaticon.com',
'fotolia.com',
'freepik.com',
'gallerystock.com',
'gettyimages.com',
'goffphotos.com',
'granger.com',
'graphicstock.com',
'imago-images.de',
'istockphoto.com',
'maryevans.com',
'mindenpictures.com',
'mintimages.com',
'mostphotos.com',
'naturepl.com',
'newsroom.ap.org',
'nhpa.co.uk',
'offset.shutterstock.com',
'pacificpressagency.com',
'panthermedia.net',
'photoshot.com',
'pictures.reuters.com',
'pixtastock.com',
'plainpicture.com',
'pond5.com',
'robertharding.com',
'sciencephoto.com',
'shutterstock.com',
'splashnews.com',
'stock.adobe.com',
'stocksy.com',
'storyblocks.com',
'trevillion.com',
'vecteezy.com',
'videoblocks.com',
'wenn.com',
'yayimages.com',
'zumapress.com',
);
/**
* Default ignored hostnames seeded on first activation.
*
* @var list<string>
*/
private const DEFAULT_FILTER_EXCLUDE = array(
'9gag.com',
'artstation.com',
'bandcamp.com',
'behance.net',
'blogger.com',
'bsky.app',
'dailymotion.com',
'deviantart.com',
'discord.com',
'douyin.com',
'dribbble.com',
'facebook.com',
'fb.com',
'flickr.com',
'gfycat.com',
'giphy.com',
'imgur.com',
'instagram.com',
'linkedin.com',
'livejournal.com',
'loom.com',
'mastodon.social',
'medium.com',
'notion.site',
'onlyfans.com',
'patreon.com',
'periscope.tv',
'pinterest.com',
'qq.com',
'reddit.com',
'snapchat.com',
'soundcloud.com',
'spotify.com',
'squarespace.com',
'streamable.com',
'substack.com',
'telegram.org',
'tenor.com',
'threads.net',
'tiktok.com',
'tumblr.com',
'twitch.tv',
'twitter.com',
'vimeo.com',
'vk.com',
'weebly.com',
'weibo.com',
'whatsapp.com',
'wix.com',
'wordpress.com',
'wordpress.org',
'x.com',
'xiaohongshu.com',
'youtube.com',
'zhihu.com',
);
/**
* Valid tab keys (order defines display order).
*
* @var list<string>
*/
private const TAB_KEYS = array( 'general', 'api', 'filters', 'external' );
/**
* Seeds the default filter lists into the option when they have not been set yet.
*
* Called once on plugin activation. Does nothing if the keys already exist,
* so existing user customisations are never overwritten.
*
* @return void
*/
public static function maybe_seed_defaults(): void {
$raw = get_option( self::OPTION_NAME, array() );
$options = is_array( $raw ) ? $raw : array();
$changed = false;
if ( ! array_key_exists( 'filter_include', $options ) ) {
$options['filter_include'] = self::DEFAULT_FILTER_INCLUDE;
$changed = true;
}
if ( ! array_key_exists( 'filter_exclude', $options ) ) {
$options['filter_exclude'] = self::DEFAULT_FILTER_EXCLUDE;
$changed = true;
}
if ( $changed ) {
update_option( self::OPTION_NAME, $options );
}
}
/**
* Returns translated tab labels keyed by tab ID.
*
* @return array<string, string>
*/
private function get_tabs(): array {
return array(
'general' => __( 'General', 'robotstxt-mediaaudit' ),
'api' => __( 'API Credentials', 'robotstxt-mediaaudit' ),
'filters' => __( 'Filters', 'robotstxt-mediaaudit' ),
'external' => __( 'External Scanning', 'robotstxt-mediaaudit' ),
);
}
/**
* Returns the active tab key derived 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'] ) : 'general';
return in_array( $raw, self::TAB_KEYS, true ) ? $raw : 'general';
}
/**
* Registers all settings, sections, and fields via the WordPress Settings API.
*
@ -42,33 +236,35 @@ class Settings {
)
);
// --- General tab ---
add_settings_section(
'mra_general',
__( 'General', 'robotstxt-mediaaudit' ),
'__return_false',
'robotstxt-mediaaudit-settings'
'',
array( $this, 'section_general_status' ),
'mra-settings-general'
);
add_settings_field(
'delete_on_uninstall',
__( 'Delete data on uninstall', 'robotstxt-mediaaudit' ),
array( $this, 'field_delete_on_uninstall' ),
'robotstxt-mediaaudit-settings',
'mra-settings-general',
'mra_general'
);
// --- API Credentials tab ---
add_settings_section(
'mra_api_credentials',
__( 'API Credentials', 'robotstxt-mediaaudit' ),
'',
'__return_false',
'robotstxt-mediaaudit-settings'
'mra-settings-api'
);
add_settings_field(
'google_vision_api_key',
__( 'Google Cloud Vision API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_google_vision_api_key' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
@ -76,7 +272,7 @@ class Settings {
'tineye_api_key',
__( 'TinEye API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_tineye_api_key' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
@ -84,7 +280,7 @@ class Settings {
'picdefense_user_id',
__( 'PicDefense User ID', 'robotstxt-mediaaudit' ),
array( $this, 'field_picdefense_user_id' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
@ -92,22 +288,54 @@ class Settings {
'picdefense_api_key',
__( 'PicDefense API Key', 'robotstxt-mediaaudit' ),
array( $this, 'field_picdefense_api_key' ),
'robotstxt-mediaaudit-settings',
'mra-settings-api',
'mra_api_credentials'
);
// --- Filters tab ---
add_settings_section(
'mra_filters_include',
__( 'Alert Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'section_filters_include' ),
'mra-settings-filters'
);
add_settings_field(
'filter_include',
__( 'Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'field_filter_include' ),
'mra-settings-filters',
'mra_filters_include'
);
add_settings_section(
'mra_filters_exclude',
__( 'Ignored Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'section_filters_exclude' ),
'mra-settings-filters'
);
add_settings_field(
'filter_exclude',
__( 'Hostnames', 'robotstxt-mediaaudit' ),
array( $this, 'field_filter_exclude' ),
'mra-settings-filters',
'mra_filters_exclude'
);
// --- External Scanning tab ---
add_settings_section(
'mra_external_scanning',
__( 'External Scanning', 'robotstxt-mediaaudit' ),
'',
'__return_false',
'robotstxt-mediaaudit-settings'
'mra-settings-external'
);
add_settings_field(
'external_batch_size',
__( 'Batch Size', 'robotstxt-mediaaudit' ),
array( $this, 'field_external_batch_size' ),
'robotstxt-mediaaudit-settings',
'mra-settings-external',
'mra_external_scanning'
);
@ -115,7 +343,7 @@ class Settings {
'rate_limit_per_minute',
__( 'Rate Limit (requests/min)', 'robotstxt-mediaaudit' ),
array( $this, 'field_rate_limit_per_minute' ),
'robotstxt-mediaaudit-settings',
'mra-settings-external',
'mra_external_scanning'
);
}
@ -123,42 +351,214 @@ class Settings {
/**
* Sanitises the settings array on save.
*
* Reads the submitted _tab sentinel to determine which fields are present
* and merges them onto the existing stored values, leaving all other tabs
* untouched.
*
* @param mixed $input Raw POST input.
*
* @return array<string, mixed>
*/
public function sanitize( $input ): array {
$output = array();
$raw = get_option( self::OPTION_NAME, array() );
$current = is_array( $raw ) ? $raw : array();
$output = $current;
if ( ! is_array( $input ) ) {
return $output;
}
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
$tab_raw = $input['_tab'] ?? '';
$tab = is_string( $tab_raw ) ? sanitize_key( $tab_raw ) : '';
$key_val = $input['google_vision_api_key'] ?? null;
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
if ( 'general' === $tab ) {
$output['delete_on_uninstall'] = ! empty( $input['delete_on_uninstall'] );
}
$tineye_val = $input['tineye_api_key'] ?? null;
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
if ( 'api' === $tab ) {
$key_val = $input['google_vision_api_key'] ?? null;
$output['google_vision_api_key'] = is_string( $key_val ) ? sanitize_text_field( $key_val ) : '';
$pd_uid_val = $input['picdefense_user_id'] ?? null;
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
$tineye_val = $input['tineye_api_key'] ?? null;
$output['tineye_api_key'] = is_string( $tineye_val ) ? sanitize_text_field( $tineye_val ) : '';
$pd_key_val = $input['picdefense_api_key'] ?? null;
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
$pd_uid_val = $input['picdefense_user_id'] ?? null;
$output['picdefense_user_id'] = is_string( $pd_uid_val ) ? sanitize_text_field( $pd_uid_val ) : '';
$batch_val = $input['external_batch_size'] ?? null;
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
$pd_key_val = $input['picdefense_api_key'] ?? null;
$output['picdefense_api_key'] = is_string( $pd_key_val ) ? sanitize_text_field( $pd_key_val ) : '';
}
$rl_val = $input['rate_limit_per_minute'] ?? null;
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
if ( 'filters' === $tab ) {
$output['filter_include'] = $this->sanitize_hostname_list( $input['filter_include'] ?? '' );
$output['filter_exclude'] = $this->sanitize_hostname_list( $input['filter_exclude'] ?? '' );
$overlap = array_intersect( $output['filter_include'], $output['filter_exclude'] );
if ( ! empty( $overlap ) ) {
$output['filter_exclude'] = array_values( array_diff( $output['filter_exclude'], $overlap ) );
add_settings_error(
self::OPTION_NAME,
'mra_filter_overlap',
sprintf(
/* translators: %s: comma-separated list of hostnames */
__( 'The following hostnames were removed from the exclusion list because they already appear in the alert list: %s', 'robotstxt-mediaaudit' ),
esc_html( implode( ', ', $overlap ) )
),
'warning'
);
}
}
if ( 'external' === $tab ) {
$batch_val = $input['external_batch_size'] ?? null;
$batch = is_numeric( $batch_val ) ? (int) $batch_val : 10;
$output['external_batch_size'] = max( 1, min( 100, $batch ) );
$rl_val = $input['rate_limit_per_minute'] ?? null;
$rl = is_numeric( $rl_val ) ? (int) $rl_val : 10;
$output['rate_limit_per_minute'] = max( 1, min( 60, $rl ) );
}
unset( $output['_tab'] );
return $output;
}
/**
* Sanitises a newline-separated list of hostnames into a deduplicated array.
*
* 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<string>
*/
private function sanitize_hostname_list( mixed $raw ): array {
if ( ! is_string( $raw ) ) {
return array();
}
$split = preg_split( '/\r?\n/', $raw );
$lines = is_array( $split ) ? $split : array();
$result = array();
foreach ( $lines as $line ) {
$h = strtolower( sanitize_text_field( trim( $line ) ) );
if ( '' === $h ) {
continue;
}
// Preserve wildcard prefix before stripping URL parts.
$prefix = '';
if ( str_starts_with( $h, '*.' ) ) {
$prefix = '*.';
$h = substr( $h, 2 );
}
// Strip scheme (http://, https://, etc.).
$stripped = preg_replace( '/^[a-z][a-z0-9+\-.]*:\/\//', '', $h );
if ( is_string( $stripped ) ) {
$h = $stripped;
}
// Strip path, query string, and fragment — keep only host[:port].
$h = substr( $h, 0, strcspn( $h, '/?#' ) );
// Strip port number.
$no_port = preg_replace( '/:\d+$/', '', $h );
if ( is_string( $no_port ) ) {
$h = $no_port;
}
$h = trim( $h, '. ' );
// Strip www. prefix — example.com already covers www.example.com at match time.
if ( str_starts_with( $h, 'www.' ) ) {
$h = substr( $h, 4 );
}
$h = $prefix . $h;
if ( preg_match( '/^(\*\.)?[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)+$/', $h ) ) {
$result[] = $h;
}
}
$result = array_values( array_unique( $result ) );
sort( $result );
return $result;
}
/**
* Renders the API credentials status block shown at the top of the General tab.
*
* @return void
*/
public function section_general_status(): void {
$gv_ok = $this->is_google_vision_configured();
$te_ok = $this->is_tineye_configured();
$pd_ok = $this->is_picdefense_configured();
?>
<div class="mra-provider-status">
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
<ul>
<li>
<?php if ( $gv_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $te_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $pd_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
PicDefense
</li>
</ul>
</div>
<?php
}
/**
* Renders the description for the "Alert Hostnames" filter section.
*
* @return void
*/
public function section_filters_include(): void {
?>
<p class="description">
<?php esc_html_e( 'Matches from these hostnames are flagged as critical copyright alerts. Entering example.com also covers all its subdomains.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the description for the "Ignored Hostnames" filter section.
*
* @return void
*/
public function section_filters_exclude(): void {
?>
<p class="description">
<?php esc_html_e( 'Matches from these hostnames are silently ignored and not counted as potential copyright issues. Entering example.com also covers all its subdomains. A hostname already present in the alert list cannot be added here.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the "Delete data on uninstall" checkbox field.
*
@ -339,6 +739,68 @@ class Settings {
<?php
}
/**
* Renders the alert hostname filter textarea.
*
* @return void
*/
public function field_filter_include(): void {
$options = (array) get_option( self::OPTION_NAME, array() );
$raw = $options['filter_include'] ?? array();
$strings = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $item ) {
if ( is_string( $item ) ) {
$strings[] = $item;
}
}
}
$value = implode( "\n", $strings );
?>
<textarea
id="filter_include"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[filter_include]"
rows="8"
class="large-text code"
placeholder="example.com"
><?php echo esc_textarea( $value ); ?></textarea>
<p class="description">
<?php esc_html_e( 'One hostname per line. example.com covers all its subdomains. Explicit wildcard: *.example.com.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the ignored hostname filter textarea.
*
* @return void
*/
public function field_filter_exclude(): void {
$options = (array) get_option( self::OPTION_NAME, array() );
$raw = $options['filter_exclude'] ?? array();
$strings = array();
if ( is_array( $raw ) ) {
foreach ( $raw as $item ) {
if ( is_string( $item ) ) {
$strings[] = $item;
}
}
}
$value = implode( "\n", $strings );
?>
<textarea
id="filter_exclude"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[filter_exclude]"
rows="8"
class="large-text code"
placeholder="cdn.example.com"
><?php echo esc_textarea( $value ); ?></textarea>
<p class="description">
<?php esc_html_e( 'One hostname per line. example.com covers all its subdomains. Explicit wildcard: *.example.com.', 'robotstxt-mediaaudit' ); ?>
</p>
<?php
}
/**
* Renders the external batch size field.
*
@ -436,49 +898,31 @@ class Settings {
wp_die( esc_html__( 'You do not have permission to access this page.', 'robotstxt-mediaaudit' ) );
}
$gv_ok = $this->is_google_vision_configured();
$te_ok = $this->is_tineye_configured();
$pd_ok = $this->is_picdefense_configured();
$tab = $this->get_active_tab();
$tabs = $this->get_tabs();
$page_url = admin_url( 'admin.php?page=robotstxt-mediaaudit-settings' );
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<div class="mra-provider-status">
<strong><?php esc_html_e( 'API Credentials Status', 'robotstxt-mediaaudit' ); ?></strong>
<ul>
<li>
<?php if ( $gv_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'Google Cloud Vision', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $te_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
<?php esc_html_e( 'TinEye', 'robotstxt-mediaaudit' ); ?>
</li>
<li>
<?php if ( $pd_ok ) : ?>
<span class="dashicons dashicons-yes-alt" style="color:green;"></span>
<?php else : ?>
<span class="dashicons dashicons-warning" style="color:orange;"></span>
<?php endif; ?>
PicDefense
</li>
</ul>
</div>
<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="options.php">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( 'robotstxt-mediaaudit-settings' );
submit_button();
?>
<?php settings_fields( self::OPTION_GROUP ); ?>
<input
type="hidden"
name="<?php echo esc_attr( self::OPTION_NAME ); ?>[_tab]"
value="<?php echo esc_attr( $tab ); ?>"
/>
<?php do_settings_sections( 'mra-settings-' . $tab ); ?>
<?php submit_button(); ?>
</form>
</div>
<?php