robotstxt-og/admin/class-robotstxt-og-admin-settings.php
2026-02-19 11:16:44 +00:00

733 lines
19 KiB
PHP

<?php
/**
* Admin Settings Class
*
* Handles administration interface and settings management.
*
* @package ROBOTSTXT_OG
* @since 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Class Robotstxt_OG_Admin_Settings
*
* Manages plugin settings page and administrative functions.
*
* @since 1.0.0
*/
class Robotstxt_OG_Admin_Settings {
/**
* Image resolver instance.
*
* @since 1.0.0
* @var Robotstxt_OG_Image_Resolver
*/
private Robotstxt_OG_Image_Resolver $resolver;
/**
* Constructor.
*
* @since 1.0.0
*
* @param Robotstxt_OG_Image_Resolver $resolver Image resolver instance.
*/
public function __construct( Robotstxt_OG_Image_Resolver $resolver ) {
$this->resolver = $resolver;
}
/**
* Initialize admin hooks.
*
* @since 1.0.0
*
* @return void
*/
public function init(): void {
add_action( 'admin_menu', array( $this, 'add_settings_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_init', array( $this, 'handle_clear_cache' ) );
add_action( 'admin_init', array( $this, 'handle_resolve_all' ) );
add_action( 'admin_init', array( $this, 'handle_clear_single' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
}
/**
* Handle clear single post cache action.
*
* @since 1.0.0
*
* @return void
*/
public function handle_clear_single(): void {
// Check if this is a single clear request.
$post_id_raw = filter_input( INPUT_GET, 'robotstxt_og_clear_single', FILTER_SANITIZE_NUMBER_INT );
if ( null === $post_id_raw ) {
return;
}
$post_id = absint( $post_id_raw );
if ( $post_id <= 0 ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_single_' . $post_id ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Clear single post cache.
$this->resolver->clear_cache( $post_id );
// Add admin notice.
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_single_cleared',
/* translators: %d: post ID */
sprintf( __( 'Successfully cleared cached fallback URL for post #%d.', 'robotstxt-og' ), $post_id ),
'success'
);
// Redirect back to diagnostics tab.
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
wp_safe_redirect(
add_query_arg( 'tab', 'diagnostics', remove_query_arg( array( 'robotstxt_og_clear_single', '_wpnonce' ), $page_url ) )
);
exit;
}
/**
* Add settings page to WordPress admin menu.
*
* @since 1.0.0
*
* @return void
*/
public function add_settings_page(): void {
add_options_page(
__( 'OpenGraph Settings', 'robotstxt-og' ),
__( 'OpenGraph', 'robotstxt-og' ),
'manage_options',
'robotstxt-og-settings',
array( $this, 'render_settings_page' )
);
}
/**
* Register plugin settings.
*
* @since 1.0.0
*
* @return void
*/
public function register_settings(): void {
// Register settings.
register_setting(
'robotstxt_og_settings',
'robotstxt_og_fallback_image',
array(
'type' => 'string',
'sanitize_callback' => array( $this, 'sanitize_image_url' ),
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_homepage_image',
array(
'type' => 'string',
'sanitize_callback' => array( $this, 'sanitize_image_url' ),
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_enable_facebook',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_enable_twitter',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => true,
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_twitter_card_type',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'default' => 'summary_large_image',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_twitter_site',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
register_setting(
'robotstxt_og_settings',
'robotstxt_og_delete_data_on_uninstall',
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
'default' => false,
)
);
// --- General Settings section ---
add_settings_section(
'robotstxt_og_main_section',
__( 'General Settings', 'robotstxt-og' ),
array( $this, 'render_main_section' ),
'robotstxt-og-settings'
);
add_settings_field(
'robotstxt_og_fallback_image',
__( 'Global Fallback Image', 'robotstxt-og' ),
array( $this, 'render_fallback_image_field' ),
'robotstxt-og-settings',
'robotstxt_og_main_section'
);
add_settings_field(
'robotstxt_og_homepage_image',
__( 'Homepage Image', 'robotstxt-og' ),
array( $this, 'render_homepage_image_field' ),
'robotstxt-og-settings',
'robotstxt_og_main_section'
);
add_settings_field(
'robotstxt_og_delete_data_on_uninstall',
__( 'Data Management', 'robotstxt-og' ),
array( $this, 'render_delete_data_field' ),
'robotstxt-og-settings',
'robotstxt_og_main_section'
);
// --- Social Media Tags section ---
add_settings_section(
'robotstxt_og_social_section',
__( 'Social Media Tags', 'robotstxt-og' ),
array( $this, 'render_social_section' ),
'robotstxt-og-settings'
);
add_settings_field(
'robotstxt_og_enable_facebook',
__( 'Facebook / OG Tags', 'robotstxt-og' ),
array( $this, 'render_enable_facebook_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
add_settings_field(
'robotstxt_og_enable_twitter',
__( 'Twitter Card Tags', 'robotstxt-og' ),
array( $this, 'render_enable_twitter_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
add_settings_field(
'robotstxt_og_twitter_card_type',
__( 'Twitter Card Type', 'robotstxt-og' ),
array( $this, 'render_twitter_card_type_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
add_settings_field(
'robotstxt_og_twitter_site',
__( 'Twitter/X Site Handle', 'robotstxt-og' ),
array( $this, 'render_twitter_site_field' ),
'robotstxt-og-settings',
'robotstxt_og_social_section'
);
}
/**
* Sanitize and validate an image URL for use as an OG image.
*
* Validates URL format and checks compatibility (JPEG/PNG). For AVIF/WebP
* and other incompatible formats, attempts to find a JPEG/PNG alternative.
* Returns empty string and adds a settings error if the URL is invalid or
* no compatible version can be found.
*
* @since 1.2.0
*
* @param string $raw Raw value from the settings form.
* @return string Validated, compatible image URL or empty string.
*/
public function sanitize_image_url( string $raw ): string {
$url = esc_url_raw( trim( $raw ) );
if ( empty( $url ) ) {
return '';
}
if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_invalid_url',
__( 'The image URL is not valid. Please enter a full URL starting with https://.', 'robotstxt-og' ),
'error'
);
return '';
}
$compatible = $this->resolver->ensure_compatible_format( $url );
if ( empty( $compatible ) ) {
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_incompatible_format',
__( 'The image URL is not in a compatible format (JPEG or PNG), and no compatible alternative could be found.', 'robotstxt-og' ),
'error'
);
return '';
}
return $compatible;
}
/**
* Render main settings section description.
*
* @since 1.0.0
*
* @return void
*/
public function render_main_section(): void {
echo '<p>';
esc_html_e( 'Configure OpenGraph image fallback behavior.', 'robotstxt-og' );
echo '</p>';
}
/**
* Render fallback image field.
*
* @since 1.0.0
*
* @return void
*/
public function render_fallback_image_field(): void {
$image_url = (string) get_option( 'robotstxt_og_fallback_image', '' );
?>
<input
type="url"
id="robotstxt_og_fallback_image"
name="robotstxt_og_fallback_image"
value="<?php echo esc_url( $image_url ); ?>"
class="large-text"
placeholder="https://example.com/fallback.jpg"
/>
<p class="description">
<?php esc_html_e( 'Used as Open Graph image when a post has no featured image or when no compatible format (JPEG/PNG) is found. Must be a direct URL to a JPEG or PNG file.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render homepage image field.
*
* @since 1.1.0
*
* @return void
*/
public function render_homepage_image_field(): void {
$image_url = (string) get_option( 'robotstxt_og_homepage_image', '' );
?>
<input
type="url"
id="robotstxt_og_homepage_image"
name="robotstxt_og_homepage_image"
value="<?php echo esc_url( $image_url ); ?>"
class="large-text"
placeholder="https://example.com/homepage.jpg"
/>
<p class="description">
<?php esc_html_e( 'Dedicated Open Graph image for the site homepage. Falls back to the Global Fallback Image if not set. Must be a direct URL to a JPEG or PNG file.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render delete data checkbox field.
*
* @since 1.0.0
*
* @return void
*/
public function render_delete_data_field(): void {
$delete_data = get_option( 'robotstxt_og_delete_data_on_uninstall', false );
?>
<label>
<input type="checkbox" name="robotstxt_og_delete_data_on_uninstall" value="1" <?php checked( $delete_data, true ); ?> />
<?php esc_html_e( 'Delete all plugin data when uninstalling', 'robotstxt-og' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Warning: This will permanently delete all cached fallback URLs and plugin settings. This action cannot be undone.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render social media tags section description.
*
* @since 1.1.0
*
* @return void
*/
public function render_social_section(): void {
echo '<p>';
esc_html_e( 'Choose which social media meta tag groups to output and configure their behavior.', 'robotstxt-og' );
echo '</p>';
}
/**
* Render enable Facebook/OG tags checkbox.
*
* @since 1.1.0
*
* @return void
*/
public function render_enable_facebook_field(): void {
$enabled = get_option( 'robotstxt_og_enable_facebook', true );
?>
<label>
<input type="checkbox" name="robotstxt_og_enable_facebook" value="1" <?php checked( $enabled, true ); ?> />
<?php esc_html_e( 'Enable Facebook / Open Graph image tags', 'robotstxt-og' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Outputs og:title, og:type, og:url, og:description, og:site_name, og:locale, og:image, og:image:width, og:image:height, og:image:type, and og:image:alt meta tags.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render enable Twitter Card tags checkbox.
*
* @since 1.1.0
*
* @return void
*/
public function render_enable_twitter_field(): void {
$enabled = get_option( 'robotstxt_og_enable_twitter', true );
?>
<label>
<input type="checkbox" name="robotstxt_og_enable_twitter" value="1" <?php checked( $enabled, true ); ?> />
<?php esc_html_e( 'Enable Twitter Card image tags', 'robotstxt-og' ); ?>
</label>
<p class="description">
<?php esc_html_e( 'Outputs twitter:card, twitter:site, and twitter:image meta tags.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render Twitter card type select field.
*
* @since 1.1.0
*
* @return void
*/
public function render_twitter_card_type_field(): void {
$card_type = get_option( 'robotstxt_og_twitter_card_type', 'summary_large_image' );
$options = array(
'summary_large_image' => __( 'Summary with large image', 'robotstxt-og' ),
'summary' => __( 'Summary (small image)', 'robotstxt-og' ),
);
?>
<select name="robotstxt_og_twitter_card_type" id="robotstxt_og_twitter_card_type">
<?php foreach ( $options as $value => $label ) : ?>
<option value="<?php echo esc_attr( $value ); ?>" <?php selected( $card_type, $value ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<p class="description">
<?php esc_html_e( 'Controls the twitter:card meta tag value. "Summary with large image" is recommended for most sites.', 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render Twitter/X site handle field.
*
* @since 1.2.0
*
* @return void
*/
public function render_twitter_site_field(): void {
$handle = (string) get_option( 'robotstxt_og_twitter_site', '' );
?>
<input
type="text"
id="robotstxt_og_twitter_site"
name="robotstxt_og_twitter_site"
value="<?php echo esc_attr( $handle ); ?>"
placeholder="@example"
class="regular-text"
/>
<p class="description">
<?php esc_html_e( "Your site's Twitter/X account handle (e.g. @example). Used for the twitter:site meta tag.", 'robotstxt-og' ); ?>
</p>
<?php
}
/**
* Render settings page.
*
* @since 1.0.0
*
* @return void
*/
public function render_settings_page(): void {
// Check user capabilities.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Include view file.
require_once ROBOTSTXT_OG_PATH . 'admin/views/settings-page.php';
}
/**
* Handle clear cache action.
*
* @since 1.0.0
*
* @return void
*/
public function handle_clear_cache(): void {
// Check if this is a cache clear request.
$clear_cache = filter_input( INPUT_GET, 'robotstxt_og_clear_cache', FILTER_SANITIZE_SPECIAL_CHARS );
if ( null === $clear_cache ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_clear_cache' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Clear all caches.
$count = $this->clear_all_caches();
// Add admin notice.
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_cache_cleared',
/* translators: %d: number of cleared cache entries */
sprintf( __( 'Successfully cleared %d cached fallback URLs.', 'robotstxt-og' ), $count ),
'success'
);
// Redirect back to tools tab.
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
wp_safe_redirect(
add_query_arg( 'tab', 'tools', remove_query_arg( array( 'robotstxt_og_clear_cache', '_wpnonce' ), $page_url ) )
);
exit;
}
/**
* Handle resolve all images action.
*
* @since 1.0.0
*
* @return void
*/
public function handle_resolve_all(): void {
// Check if this is a resolve all request.
$resolve_all = filter_input( INPUT_GET, 'robotstxt_og_resolve_all', FILTER_SANITIZE_SPECIAL_CHARS );
if ( null === $resolve_all ) {
return;
}
// Verify nonce.
$nonce_raw = filter_input( INPUT_GET, '_wpnonce', FILTER_SANITIZE_SPECIAL_CHARS );
$nonce = $nonce_raw ? sanitize_text_field( wp_unslash( $nonce_raw ) ) : '';
if ( ! wp_verify_nonce( $nonce, 'robotstxt_og_resolve_all' ) ) {
wp_die( esc_html__( 'Security check failed', 'robotstxt-og' ) );
}
// Check permissions.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'robotstxt-og' ) );
}
// Resolve all images.
$result = $this->resolve_all_images();
// Add admin notice.
add_settings_error(
'robotstxt_og_messages',
'robotstxt_og_images_resolved',
/* translators: 1: number of successful resolutions 2: number of failed resolutions */
sprintf( __( 'Batch resolution complete. Success: %1$d, Failed: %2$d', 'robotstxt-og' ), $result['success'], $result['failed'] ),
$result['failed'] > 0 ? 'warning' : 'success'
);
// Redirect back to tools tab.
$page_url = admin_url( 'options-general.php?page=robotstxt-og-settings' );
wp_safe_redirect(
add_query_arg( 'tab', 'tools', remove_query_arg( array( 'robotstxt_og_resolve_all', '_wpnonce' ), $page_url ) )
);
exit;
}
/**
* Clear all cached fallback URLs.
*
* @since 1.0.0
*
* @return int Number of entries deleted.
*/
private function clear_all_caches(): int {
global $wpdb;
// Bulk-delete all cache entries by meta key. No WP API performs this
// in a single query while also returning the affected row count.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$result = $wpdb->delete(
$wpdb->postmeta,
array( 'meta_key' => '_og_image_fallback_url' ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
array( '%s' )
);
// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
return $result ? (int) $result : 0;
}
/**
* Resolve all images in batch.
*
* @since 1.0.0
*
* @return array{success: int, failed: int} Result counts.
*/
private function resolve_all_images(): array {
// Query all posts with featured images.
$posts = get_posts(
array(
'post_type' => 'any',
'post_status' => 'any',
'posts_per_page' => -1,
'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
array(
'key' => '_thumbnail_id',
'compare' => 'EXISTS',
),
),
'fields' => 'ids',
)
);
$success = 0;
$failed = 0;
foreach ( $posts as $post_id ) {
// Clear cache first.
$this->resolver->clear_cache( $post_id );
// Resolve image.
$url = $this->resolver->resolve_image( $post_id );
if ( ! empty( $url ) ) {
++$success;
} else {
++$failed;
}
}
return array(
'success' => $success,
'failed' => $failed,
);
}
/**
* Enqueue admin assets.
*
* @since 1.0.0
*
* @param string $hook Current admin page hook.
* @return void
*/
public function enqueue_admin_assets( string $hook ): void {
// Only on our settings page.
if ( 'settings_page_robotstxt-og-settings' !== $hook ) {
return;
}
// Enqueue custom admin JS.
wp_enqueue_script(
'robotstxt-og-admin',
ROBOTSTXT_OG_URL . 'assets/admin.js',
array( 'jquery' ),
ROBOTSTXT_OG_VERSION,
true
);
// Pass translated strings to JavaScript.
wp_localize_script(
'robotstxt-og-admin',
'robotstxt_og_admin',
array(
'confirm_clear_cache' => __( 'Are you sure you want to clear all cached fallback URLs? This action cannot be undone.', 'robotstxt-og' ),
'confirm_resolve_all' => __( 'Are you sure you want to re-resolve all images? This may take some time on large sites.', 'robotstxt-og' ),
)
);
// Enqueue custom admin CSS.
wp_enqueue_style(
'robotstxt-og-admin',
ROBOTSTXT_OG_URL . 'assets/admin.css',
array(),
ROBOTSTXT_OG_VERSION
);
}
}