'X (Twitter)', 'linkedin' => 'LinkedIn', 'facebook' => 'Facebook', 'telegram' => 'Telegram', 'whatsapp' => 'WhatsApp', 'email' => 'Email', 'raindrop' => 'Raindrop', 'reddit' => 'Reddit', 'bluesky' => 'Bluesky', 'line' => 'LINE', 'mastodon' => 'Mastodon', 'threads' => 'Threads', 'pinterest' => 'Pinterest', 'claude' => 'Claude AI', 'chatgpt' => 'ChatGPT', 'google_ai' => 'Google AI', 'gemini' => 'Gemini', 'grok' => 'Grok', 'perplexity' => 'Perplexity', 'deepseek' => 'DeepSeek', 'mistral' => 'Mistral AI', 'copilot' => 'Microsoft Copilot', 'qwen' => 'Qwen', 'meta_ai' => 'Meta AI', // Browser actions (v2.5.0): no destination site, they act on the page. // Translatable, unlike the platform names above, which are brands. 'copy_link' => __( 'Copy link', 'ai-share-summarize' ), 'web_share' => __( 'Native share', 'ai-share-summarize' ), // Google preferred source (v2.6.0): a link to Google's own tool, not a // share. Translatable for the same reason as the browser actions. 'google_preferred' => __( 'Google preferred source', 'ai-share-summarize' ), ); } /** * Get social media buttons list * * @return array Social media button keys. */ function ayudawp_aiss_get_social_buttons() { return array( 'twitter', 'linkedin', 'facebook', 'telegram', 'whatsapp', 'email', 'raindrop', 'reddit', 'bluesky', 'line', 'mastodon', 'threads', 'pinterest', 'copy_link', 'web_share', 'google_preferred' ); } /** * Get AI buttons list * * @return array AI button keys. */ function ayudawp_aiss_get_ai_buttons() { return array( 'claude', 'chatgpt', 'google_ai', 'gemini', 'grok', 'perplexity', 'deepseek', 'mistral', 'copilot', 'qwen', 'meta_ai' ); } /** * Sanitize a title_style value against an allowlist of HTML tag names. * * The value is emitted in tag-name position (`<{$tag}>`), where `esc_attr()` * is not enough: it does not encode spaces or `=`, so an attacker could turn * the opening tag into an arbitrary element with event-handler attributes. * Anything outside the allowlist falls back to `span`. * * @since 2.0.4 * @param string $value Untrusted tag name. * @return string Safe tag name from the allowlist. */ function ayudawp_aiss_sanitize_title_style( $value ) { $allowed = array( 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'div' ); $value = is_string( $value ) ? strtolower( trim( $value ) ) : ''; return in_array( $value, $allowed, true ) ? $value : 'span'; } /** * Build an opening title/section tag from a safe tag name (v2.1.0) * * The tag name is validated against the allowlist *inside* this helper, via * ayudawp_aiss_sanitize_title_style(), so the allowlist can never be bypassed * by a caller that builds the tag itself. This replaces the inline * `'<' . esc_attr( $tag ) . ' class=...>'` pattern: esc_attr() does not encode * spaces or `=`, so emitting an unsanitized value in tag-name position was the * class of bug behind the 2.0.x stored XSS. Keeping the allowlist here removes * that recurring "looks vulnerable" pattern from the codebase. * * @since 2.1.0 * @since 2.5.0 Optional id, used to wire section titles to their button group * with aria-labelledby. * @param string $style Untrusted tag name (validated against the allowlist). * @param string $class CSS class(es) for the element (escaped here). * @param string $id Optional element id (escaped here). * @return string Opening tag, e.g. '

'. */ function ayudawp_aiss_open_tag( $style, $class = '', $id = '' ) { $tag = ayudawp_aiss_sanitize_title_style( $style ); $class = trim( (string) $class ); $id = trim( (string) $id ); $attributes = ''; if ( '' !== $id ) { $attributes .= ' id="' . esc_attr( $id ) . '"'; } if ( '' !== $class ) { $attributes .= ' class="' . esc_attr( $class ) . '"'; } return '<' . $tag . $attributes . '>'; } /** * Build the matching closing tag for ayudawp_aiss_open_tag() (v2.1.0) * * @since 2.1.0 * @param string $style Untrusted tag name (validated against the allowlist). * @return string Closing tag, e.g. '

'. */ function ayudawp_aiss_close_tag( $style ) { return ''; } /** * Validate and sanitize plugin options * * Handles the buttons/general option only. The AI-summary settings moved to * their own option (`ayudawp_aiss_summary_options`) in 2.3.0 — see * ayudawp_aiss_validate_summary_options(). Any `ai_summary_*` keys still * present in the stored value are a frozen pre-2.3.0 snapshot kept for * rollback safety; they are carried over untouched, never re-validated. * * @param array $input Raw input data. * @return array Validated and sanitized options. */ function ayudawp_aiss_validate_options( $input ) { $validated = array(); // Text fields. $text_fields = array( 'twitter_handle', 'title_text', 'title_style', 'button_order', 'button_alignment', 'button_colors', 'icon_style', 'seo_button_type', 'data_retention', 'button_size', 'ai_section_title', 'social_section_title', 'mastodon_instance', 'custom_color_bg', 'custom_color_text', 'dark_mode_adaptation', // Mobile compact mode and UTM tagging (v2.5.0). 'mobile_compact', 'utm_campaign', // Google preferred source button (v2.6.0). 'google_preferred_placement', 'google_section_title', ); $textarea_fields = array( 'default_prompt', 'custom_text' ); $array_fields = array( 'enabled_buttons', 'button_custom_order_ai', 'button_custom_order_social' ); $boolean_fields = array( 'show_icons', 'delete_data_on_uninstall', 'exclude_noindex', 'separator_top', 'separator_bottom', 'floating_bar_autohide', 'floating_bar_hide_mobile', 'floating_bar_hide_with_inline', 'utm_enabled', ); foreach ( $text_fields as $field ) { if ( isset( $input[ $field ] ) ) { $validated[ $field ] = sanitize_text_field( $input[ $field ] ); } } // title_style is emitted in tag-name position; restrict it to an allowlist. if ( isset( $validated['title_style'] ) ) { $validated['title_style'] = ayudawp_aiss_sanitize_title_style( $validated['title_style'] ); } foreach ( $textarea_fields as $field ) { if ( isset( $input[ $field ] ) ) { $validated[ $field ] = sanitize_textarea_field( $input[ $field ] ); } } foreach ( $array_fields as $field ) { if ( isset( $input[ $field ] ) && is_array( $input[ $field ] ) ) { $validated[ $field ] = array_map( 'sanitize_text_field', $input[ $field ] ); } else { $validated[ $field ] = array(); } } foreach ( $boolean_fields as $field ) { $validated[ $field ] = isset( $input[ $field ] ) ? (bool) $input[ $field ] : false; } // Validate seo_button_type. if ( isset( $validated['seo_button_type'] ) && ! in_array( $validated['seo_button_type'], array( 'link', 'button' ), true ) ) { $validated['seo_button_type'] = 'link'; } // Validate button_alignment. if ( isset( $validated['button_alignment'] ) && ! in_array( $validated['button_alignment'], array( 'left', 'center' ), true ) ) { $validated['button_alignment'] = 'left'; } // Validate data_retention. $valid_retention = array( '1_month', '3_months', '6_months', '1_year', 'forever' ); if ( isset( $validated['data_retention'] ) && ! in_array( $validated['data_retention'], $valid_retention, true ) ) { $validated['data_retention'] = 'forever'; } // Validate button_size. $valid_sizes = array( 'compact', 'normal', 'large', 'fluid' ); if ( isset( $validated['button_size'] ) && ! in_array( $validated['button_size'], $valid_sizes, true ) ) { $validated['button_size'] = 'normal'; } // Validate button_colors. $valid_styles = array( 'minimal', 'brand', 'outline', 'dark', 'icons-only', 'custom' ); if ( isset( $validated['button_colors'] ) && ! in_array( $validated['button_colors'], $valid_styles, true ) ) { $validated['button_colors'] = 'minimal'; } /* * Placements (v2.5.0): a map of destination => position, where a key that * is not present means "not there". Both the destination and the position * are checked against the lists the screen paints, so a form that posts a * post type that no longer exists cannot write it. */ $targets = ayudawp_aiss_get_placement_targets(); foreach ( array( 'placements', 'floating_bar_placements' ) as $map_key ) { $positions = ayudawp_aiss_get_placement_positions( $map_key ); $fallback = ayudawp_aiss_get_default_placement_position( $map_key ); $validated[ $map_key ] = array(); // The form posts two halves: which destinations are ticked, and the // position chosen for every row (ticked or not, so unticking and // reticking a row does not lose the position). Only the intersection // is stored. $enabled = isset( $input[ $map_key . '_on' ] ) && is_array( $input[ $map_key . '_on' ] ) ? array_map( 'sanitize_key', $input[ $map_key . '_on' ] ) : array(); $chosen = isset( $input[ $map_key ] ) && is_array( $input[ $map_key ] ) ? $input[ $map_key ] : array(); foreach ( $enabled as $target ) { if ( ! isset( $targets[ $target ] ) ) { continue; } $position = isset( $chosen[ $target ] ) ? sanitize_key( $chosen[ $target ] ) : $fallback; $validated[ $map_key ][ $target ] = isset( $positions[ $position ] ) ? $position : $fallback; } } // Where the Google preferred source button goes (v2.6.0). if ( isset( $validated['google_preferred_placement'] ) && ! in_array( $validated['google_preferred_placement'], array( 'social', 'section', 'outside' ), true ) ) { $validated['google_preferred_placement'] = 'social'; } // Validate mobile_compact (v2.5.0). $valid_compact_modes = array( 'disabled', 'single', 'partial' ); if ( isset( $validated['mobile_compact'] ) && ! in_array( $validated['mobile_compact'], $valid_compact_modes, true ) ) { $validated['mobile_compact'] = 'disabled'; } /* * How many buttons stay visible in the "N visible + more" compact mode. * Clamped rather than rejected: the field is a number input, so an out of * range value is a typo, not an attack, and falling back to the default * would silently discard what the admin meant. */ if ( isset( $input['mobile_compact_visible'] ) ) { $visible = (int) $input['mobile_compact_visible']; $validated['mobile_compact_visible'] = max( 1, min( 5, $visible ) ); } // Validate dark_mode_adaptation. $valid_dark_modes = array( 'disabled', 'auto' ); if ( isset( $validated['dark_mode_adaptation'] ) && ! in_array( $validated['dark_mode_adaptation'], $valid_dark_modes, true ) ) { $validated['dark_mode_adaptation'] = 'disabled'; } // Sanitize hex colors. if ( isset( $validated['custom_color_bg'] ) ) { $validated['custom_color_bg'] = sanitize_hex_color( $validated['custom_color_bg'] ); } if ( isset( $validated['custom_color_text'] ) ) { $validated['custom_color_text'] = sanitize_hex_color( $validated['custom_color_text'] ); } // Sanitize mastodon instance URL. if ( isset( $validated['mastodon_instance'] ) ) { $validated['mastodon_instance'] = sanitize_text_field( wp_unslash( $validated['mastodon_instance'] ) ); // Remove protocol and trailing slashes. $validated['mastodon_instance'] = preg_replace( '#^https?://#', '', $validated['mastodon_instance'] ); $validated['mastodon_instance'] = rtrim( $validated['mastodon_instance'], '/' ); } /* * The frozen pre-2.3.0 `ai_summary_*` snapshot was carried over here on * every save until 2.6.0, so a rollback to 2.2.x found its configuration. * The 2.6.0 migration deletes it, and without this carry-over the next * save does not bring it back. A rollback is safe to 2.3.0 or later, where * the summary has its own option. */ $stored = get_option( 'ayudawp_aiss_options', array() ); if ( is_array( $stored ) ) { /* * Rollback snapshot of the three keys `placements` replaced in 2.5.0 * (`display_locations`, `auto_insert_content_types` and * `auto_insert_position`). The form no longer posts them, so without * this they would disappear on the first save and a rollback to 2.4.x * would land on a site with no buttons anywhere. Kept when the 2.3.0 * snapshot went in 2.6.0: 2.5.0 is recent, and its removal has its own * release once the 2.5 series has settled (roadmap, entry 38). */ foreach ( array( 'display_locations', 'auto_insert_content_types', 'auto_insert_position' ) as $legacy_key ) { if ( isset( $stored[ $legacy_key ] ) && ! isset( $validated[ $legacy_key ] ) ) { $validated[ $legacy_key ] = $stored[ $legacy_key ]; } } } /* * A prompt identical to the bundled default is not stored at all (v2.4.1), * so it keeps being resolved from the language pack on every request and * the AI is asked for the summary in the language of each reader. An empty * prompt is a different thing and is stored: it means send no instructions, * only the source URL. */ if ( isset( $validated['default_prompt'] ) && ayudawp_aiss_is_default_prompt( $validated['default_prompt'] ) ) { unset( $validated['default_prompt'] ); } return $validated; } /** * Whether a stored prompt is just a copy of the bundled default. * * Until 2.4.1 the prompt was stored verbatim: activation wrote it, and the * settings field came pre-filled, so the first save on the Share Buttons tab * stored whatever the admin was seeing. From then on the buttons sent that * exact text to the AI, so the prompt stopped going through translation and * stayed in one language. On a multilingual site that means every reader gets * the summary requested in the language of whoever saved the settings. * * Every locale installed on the site is checked, plus the original English, * which covers the language the admin could have been using. A prompt the site * actually wrote never matches and is left untouched. * * @since 2.4.1 * @param string $prompt Stored prompt. * @return bool True when the prompt is a bundled default in some locale. */ function ayudawp_aiss_is_default_prompt( $prompt ) { $locales = array_unique( array_merge( array( 'en_US', get_locale() ), get_available_languages() ) ); foreach ( $locales as $locale ) { $switched = switch_to_locale( $locale ); $default = ayudawp_aiss_get_default_prompt(); if ( $switched ) { restore_previous_locale(); } if ( $prompt === $default ) { return true; } } return false; } /** * Validate and sanitize the AI-summary options (v2.3.0) * * Sanitize callback for `ayudawp_aiss_summary_options`, the dedicated option * behind the AI Summary settings tab. Every key is always emitted with an * explicit value, so the stored option is complete and unambiguous: an empty * `ai_summary_content_types` list genuinely means "no content types" (the * form ships a hidden `ai_summary_content_types_submitted` marker so an * all-unchecked group can be told apart from a request that did not include * the field at all). * * @since 2.3.0 * @param array $input Raw input data. * @return array Validated and sanitized summary options. */ function ayudawp_aiss_validate_summary_options( $input ) { if ( ! is_array( $input ) ) { $input = array(); } $validated = array(); // Generation mode — explicit allowlist (v2.2.0). $valid_summary_modes = array( 'ai_fallback', 'ai_only', 'extractive_only', 'disabled' ); $validated['ai_summary_mode'] = isset( $input['ai_summary_mode'] ) && in_array( $input['ai_summary_mode'], $valid_summary_modes, true ) ? $input['ai_summary_mode'] : 'ai_fallback'; // Model — '' (automatic) or a "provider:model" pair restricted to a safe // charset. When the live model list is available the pair must exist in // it; if enumeration is momentarily unavailable we keep the sanitized // value rather than wiping the admin's choice. $validated['ai_summary_model'] = ''; if ( isset( $input['ai_summary_model'] ) && '' !== $input['ai_summary_model'] ) { $candidate = preg_replace( '/[^a-zA-Z0-9:._-]/', '', (string) $input['ai_summary_model'] ); if ( '' !== $candidate && 1 === substr_count( $candidate, ':' ) ) { list( $cand_provider, $cand_model ) = explode( ':', $candidate, 2 ); if ( '' !== $cand_provider && '' !== $cand_model ) { $available = ayudawp_aiss_get_available_models(); $is_known = false; if ( isset( $available[ $cand_provider ]['models'] ) ) { foreach ( $available[ $cand_provider ]['models'] as $known_model ) { if ( $known_model['id'] === $cand_model ) { $is_known = true; break; } } } if ( $is_known || empty( $available ) ) { $validated['ai_summary_model'] = $candidate; } } } } // Content types. The hidden marker distinguishes "submitted with every // checkbox unchecked" (a deliberate empty list) from input that simply // did not include the field (programmatic partial update: keep stored). if ( isset( $input['ai_summary_content_types'] ) && is_array( $input['ai_summary_content_types'] ) ) { $validated['ai_summary_content_types'] = array_values( array_map( 'sanitize_text_field', $input['ai_summary_content_types'] ) ); } elseif ( isset( $input['ai_summary_content_types_submitted'] ) ) { $validated['ai_summary_content_types'] = array(); } else { $stored = ayudawp_aiss_get_summary_options(); $validated['ai_summary_content_types'] = isset( $stored['ai_summary_content_types'] ) && is_array( $stored['ai_summary_content_types'] ) ? array_values( array_map( 'strval', $stored['ai_summary_content_types'] ) ) : array( 'post' ); } /* * Placements (v2.5.0): one row per content type with its own position, * same table and same sanitizing as the buttons. `ai_summary_content_types` * and `ai_summary_position` are kept below as a rollback snapshot. */ $summary_targets = ayudawp_aiss_get_placement_targets( 'ai_summary_placements' ); $summary_positions = ayudawp_aiss_get_placement_positions( 'ai_summary_placements' ); $summary_fallback = ayudawp_aiss_get_default_placement_position( 'ai_summary_placements' ); $validated['ai_summary_placements'] = array(); $summary_enabled = isset( $input['ai_summary_placements_on'] ) && is_array( $input['ai_summary_placements_on'] ) ? array_map( 'sanitize_key', $input['ai_summary_placements_on'] ) : array(); $summary_chosen = isset( $input['ai_summary_placements'] ) && is_array( $input['ai_summary_placements'] ) ? $input['ai_summary_placements'] : array(); foreach ( $summary_enabled as $target ) { if ( ! isset( $summary_targets[ $target ] ) ) { continue; } $position = isset( $summary_chosen[ $target ] ) ? sanitize_key( $summary_chosen[ $target ] ) : $summary_fallback; $validated['ai_summary_placements'][ $target ] = isset( $summary_positions[ $position ] ) ? $position : $summary_fallback; } // Position — allowlist. Legacy key, carried over for rollback. $valid_summary_positions = array( 'before_buttons', 'after_buttons', 'before_content', 'disabled' ); $validated['ai_summary_position'] = isset( $input['ai_summary_position'] ) && in_array( $input['ai_summary_position'], $valid_summary_positions, true ) ? $input['ai_summary_position'] : 'before_buttons'; // Block style — allowlist. $valid_summary_styles = array( 'minimal', 'outline', 'brand', 'dark', 'custom' ); $validated['ai_summary_style'] = isset( $input['ai_summary_style'] ) && in_array( $input['ai_summary_style'], $valid_summary_styles, true ) ? $input['ai_summary_style'] : 'minimal'; // Icon position — allowlist. $valid_icon_positions = array( 'left', 'right', 'hidden' ); $validated['ai_summary_icon_position'] = isset( $input['ai_summary_icon_position'] ) && in_array( $input['ai_summary_icon_position'], $valid_icon_positions, true ) ? $input['ai_summary_icon_position'] : 'left'; // Sentence count — integer in 1..5. $validated['ai_summary_sentences'] = isset( $input['ai_summary_sentences'] ) ? max( 1, min( 5, absint( $input['ai_summary_sentences'] ) ) ) : 3; // Free-text labels. $validated['ai_summary_label'] = isset( $input['ai_summary_label'] ) ? sanitize_text_field( $input['ai_summary_label'] ) : ''; $validated['ai_summary_generate_button_label'] = isset( $input['ai_summary_generate_button_label'] ) ? sanitize_text_field( $input['ai_summary_generate_button_label'] ) : ''; // Custom colors. $validated['ai_summary_custom_bg'] = isset( $input['ai_summary_custom_bg'] ) ? sanitize_hex_color( sanitize_text_field( $input['ai_summary_custom_bg'] ) ) : '#ffffff'; $validated['ai_summary_custom_text'] = isset( $input['ai_summary_custom_text'] ) ? sanitize_hex_color( sanitize_text_field( $input['ai_summary_custom_text'] ) ) : '#1a1a1a'; // Booleans (unchecked checkboxes are simply absent from the POST). $validated['ai_summary_collapsed_default'] = ! empty( $input['ai_summary_collapsed_default'] ); $validated['ai_summary_bullets'] = ! empty( $input['ai_summary_bullets'] ); $validated['ai_summary_frontend_button'] = ! empty( $input['ai_summary_frontend_button'] ); $validated['ai_summary_frontend_force_extractive'] = ! empty( $input['ai_summary_frontend_force_extractive'] ); /* * Custom content inside the box (v2.7.0). Heights in pixels, 0 meaning no * limit, and a maximum below the minimum is raised to it rather than * producing a space that can never fit its content. A label text equal to * the default in any installed language is not stored, so the default * keeps coming from the language pack (empty means the default). */ $validated['ai_summary_custom_content_enabled'] = ! empty( $input['ai_summary_custom_content_enabled'] ); $validated['ai_summary_custom_content_label'] = ! empty( $input['ai_summary_custom_content_label'] ); $validated['ai_summary_custom_content_label_text'] = isset( $input['ai_summary_custom_content_label_text'] ) ? sanitize_text_field( $input['ai_summary_custom_content_label_text'] ) : ''; if ( ayudawp_aiss_is_default_custom_label( $validated['ai_summary_custom_content_label_text'] ) ) { $validated['ai_summary_custom_content_label_text'] = ''; } $validated['ai_summary_custom_content_min_height'] = isset( $input['ai_summary_custom_content_min_height'] ) ? min( 2000, absint( $input['ai_summary_custom_content_min_height'] ) ) : 0; $validated['ai_summary_custom_content_max_height'] = isset( $input['ai_summary_custom_content_max_height'] ) ? min( 2000, absint( $input['ai_summary_custom_content_max_height'] ) ) : 0; if ( $validated['ai_summary_custom_content_max_height'] > 0 && $validated['ai_summary_custom_content_max_height'] < $validated['ai_summary_custom_content_min_height'] ) { $validated['ai_summary_custom_content_max_height'] = $validated['ai_summary_custom_content_min_height']; } $validated['ai_summary_custom_content_code'] = ayudawp_aiss_validate_summary_custom_code( $input ); return $validated; } /** * Whether a label text is just the default label in some language (v2.7.0) * * Same check as ayudawp_aiss_is_default_prompt(): every installed locale plus * the original English. * * @since 2.7.0 * @param string $text Label text to check. * @return bool True when it is the default label in some locale. */ function ayudawp_aiss_is_default_custom_label( $text ) { if ( '' === $text ) { return false; } $locales = array_unique( array_merge( array( 'en_US', get_locale() ), get_available_languages() ) ); foreach ( $locales as $locale ) { $switched = switch_to_locale( $locale ); $default = __( 'Advertisement', 'ai-share-summarize' ); if ( $switched ) { restore_previous_locale(); } if ( $text === $default ) { return true; } } return false; } /** * The custom content to store for the summary box (v2.7.0) * * Text, HTML or JavaScript kept exactly as typed: running whatever script was * pasted (an ad, a form, a map) is the feature, so there is nothing to * sanitize it into. That is why only a user with `unfiltered_html` can change it, on top of the * `manage_options` the settings screen already requires. On a single site * both belong to the administrator. On a network only the super admin has * `unfiltered_html` (`map_meta_cap()`, `wp-includes/capabilities.php:595-603` * in 7.1.3): a subsite administrator has `manage_options`, and letting them * store a script would let them run it in the super admin's session when * they visit that subsite. Under DISALLOW_UNFILTERED_HTML nobody has it. * * Anyone else keeps what is stored, untouched. A filtered copy (kses) would * leave content that silently stops working, which is worse than no change. * A request that does not carry the key (a programmatic partial update, an * upgrade routine) keeps it too. * * @since 2.7.0 * @param array $input Raw input of the summary options. * @return string Custom content to store. */ function ayudawp_aiss_validate_summary_custom_code( $input ) { $stored = ayudawp_aiss_get_summary_options(); $stored = isset( $stored['ai_summary_custom_content_code'] ) && is_string( $stored['ai_summary_custom_content_code'] ) ? $stored['ai_summary_custom_content_code'] : ''; if ( ! isset( $input['ai_summary_custom_content_code'] ) || ! is_string( $input['ai_summary_custom_content_code'] ) ) { return $stored; } if ( ! current_user_can( 'unfiltered_html' ) ) { return $stored; } $code = trim( $input['ai_summary_custom_content_code'] ); // The code is printed inside a