providers = new stdClass(); } /** * Load all available ad providers * * Also, set selected as ACM_Provider $current_provider which holds all necessary configuration properties. */ function action_load_providers() { $providers_dir = dirname( AD_CODE_MANAGER_FILE ) . '/src/Providers/'; $providers_files = array_diff( scandir( $providers_dir ), array( '..', '.' ) ); foreach ( $providers_files as $providers_file ) { include_once $providers_dir . '/' . $providers_file; $module_dir = str_replace( array( 'class-', '.php' ), '', $providers_file ); $tmp = explode( '-', $module_dir ); $class_name = ''; $slug_name = ''; $table_class_name = ''; foreach ( $tmp as $word ) { if ( $word === 'class' ) { continue; } $class_name .= ucfirst( $word ) . '_'; $slug_name .= $word . '_'; } $table_class_name = $class_name . 'ACM_WP_List_Table'; $class_name .= 'ACM_Provider'; $slug_name = rtrim( $slug_name, '_' ); // Store class names, but don't instantiate // We don't need them all at once if ( class_exists( $class_name ) ) { $this->providers->$slug_name = array( 'provider' => $class_name, 'table' => $table_class_name, ); } } /** * Filters the registered ad providers. * * We've already gathered a list of default Providers by scanning the ACM plugin * directory for classes that we can use. To add a provider included via * a different directory, use this filter. * * @since 0.1.3 * * @param object $providers Object containing provider configurations, keyed by slug. * Each provider has 'provider' (class name) and 'table' (list table class) keys. */ $this->providers = apply_filters( 'acm_register_provider_slug', $this->providers ); /** * Filters the current ad provider slug. * * By default, the provider selected in the admin UI is used. * Use this filter to programmatically switch to a different ad provider. * * @since 0.1.3 * * @param string|false $provider The provider slug (e.g., 'doubleclick_for_publishers', * 'google_adsense'). False if not set. */ $this->current_provider_slug = apply_filters( 'acm_provider_slug', $this->get_option( 'provider' ) ); // Instantiate one that we need if ( isset( $this->providers->{$this->current_provider_slug} ) ) { $current_provider_class = $this->providers->{$this->current_provider_slug}['provider']; $this->current_provider = new $current_provider_class(); } // Nothing to do without a provider if ( ! is_object( $this->current_provider ) ) { return; } /** * Filters the whitelisted script URLs for ad codes. * * A security filter to control which ad code script URLs can be added in the admin. * URLs not matching domains in this list will be rejected. * * @since 0.1 * * @param array $whitelisted_urls Array of whitelisted domain names (e.g., 'googleads.g.doubleclick.net'). */ $this->current_provider->whitelisted_script_urls = apply_filters( 'acm_whitelisted_script_urls', $this->current_provider->whitelisted_script_urls ); } /** * Code to run on WordPress' 'init' hook * * @since 0.1 */ function action_init() { // Allow other conditionals to be used $this->whitelisted_conditionals = array( 'is_home', 'is_front_page', 'is_category', 'has_category', 'is_single', 'is_page', 'is_tag', 'has_tag', ); /** * Filters the whitelisted conditional functions. * * Extend the list of usable conditional functions with your own. * These functions determine when an ad code should be displayed. * * @since 0.1 * * @param array $conditionals Array of whitelisted function names * (e.g., 'is_home', 'is_single', 'is_category'). */ $this->whitelisted_conditionals = apply_filters( 'acm_whitelisted_conditionals', $this->whitelisted_conditionals ); /** * Filters the default logical operator for conditionals. * * Determines how multiple conditionals are evaluated together. * 'OR' means any conditional can match; 'AND' means all must match. * * @since 0.1 * * @param string $operator The logical operator. Default 'OR'. Accepts 'OR' or 'AND'. */ $this->logical_operator = apply_filters( 'acm_logical_operator', 'OR' ); /** * Filters the capability required to manage ads. * * Controls which users can access the Ad Code Manager admin interface. * * @since 0.1 * * @param string $capability The capability required. Default 'manage_options'. */ $this->manage_ads_cap = apply_filters( 'acm_manage_ads_cap', $this->manage_ads_cap ); // Load default ad tags for provider $this->ad_tag_ids = $this->current_provider->ad_tag_ids; /** * Filters the registered ad tag IDs. * * Extend the set of default tag IDs. Ad tag IDs are used as parameters * for template tags, e.g., `do_action( 'acm_tag', 'my_top_leaderboard' )`. * * @since 0.1 * * @param array $ad_tag_ids Array of ad tag configurations. Each tag has: * - 'tag': (string) The tag identifier. * - 'url_vars': (array) Variables for URL token replacement. * - 'enable_ui_mapping': (bool) Optional. Whether tag can be mapped in admin UI. */ $this->ad_tag_ids = apply_filters( 'acm_ad_tag_ids', $this->ad_tag_ids ); /** * Filters the ad code arguments/fields. * * Allows customisation of the fields displayed in the admin UI for ad codes. * Useful for adding custom fields or modifying existing ones. * * @since 0.1 * * @param array $ad_code_args Array of field configurations. Each field has: * - 'key': (string) Field identifier. * - 'label': (string) Display label. * - 'editable': (bool) Whether field is editable. * - 'required': (bool) Whether field is required. * - 'type': (string) Optional. Field type (e.g., 'select'). * - 'options': (array) Optional. Options for select fields. */ $this->current_provider->ad_code_args = apply_filters( 'acm_ad_code_args', $this->current_provider->ad_code_args ); $this->register_acm_post_type(); // Ad tags are only run on the frontend if ( ! is_admin() ) { /** * Action: acm_tag * * Displays an ad tag on the frontend. Use this action in your theme * templates to output ads. * * Example: do_action( 'acm_tag', 'my_leaderboard' ); * * @since 0.1 * * @param string $tag_id The unique identifier for the ad tag to display. */ add_action( 'acm_tag', array( $this, 'action_acm_tag' ) ); add_filter( 'acm_output_tokens', array( $this, 'filter_output_tokens' ), 5, 3 ); } // Load all of our registered ad codes $this->register_ad_codes( $this->get_ad_codes() ); } /** * Register our custom post type to store ad codes * * @since 0.1 */ function register_acm_post_type() { $labels = array( 'name' => _x( 'Ad Codes', 'Post Type General Name', 'ad-code-manager' ), 'singular_name' => _x( 'Ad Code', 'Post Type Singular Name', 'ad-code-manager' ), ); $args = array( 'labels' => $labels, 'public' => false, 'rewrite' => false, ); register_post_type( $this->post_type, $args ); } /** * Get all ACM options * * @since 0.4 */ function get_options() { $default_provider = 'doubleclick_for_publishers'; // Make sure our default provider exists. Otherwise, the sky will fall on our head if ( ! isset( $this->providers->$default_provider ) ) { foreach ( $this->providers as $slug => $provider ) { $default_provider = $slug; break; } } $defaults = array( 'provider' => $default_provider, ); $options = get_option( 'acm_options', array() ); return array_merge( $defaults, $options ); } /** * Get an ACM option * * @since 0.4 */ function get_option( $key ) { $options = $this->get_options(); if ( isset( $options[ $key ] ) ) { return $options[ $key ]; } else { return false; } } /** * Update an ACM option * * @since 0.4 */ function update_options( $new_options ) { $options = $this->get_options(); $options = array_merge( $options, $new_options ); update_option( 'acm_options', $options ); } /** * Handle any Add, Edit, or Delete actions from the admin interface * Hooks into admin ajax because it's the proper context for these sort of actions * * @since 0.2 */ function handle_admin_action() { if ( ! isset( $_REQUEST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_REQUEST['nonce'] ), 'acm-admin-action' ) ) { wp_die( esc_html__( 'Doing something fishy, eh?', 'ad-code-manager' ) ); } if ( ! current_user_can( $this->manage_ads_cap ) ) { wp_die( esc_html__( 'You do not have the necessary permissions to perform this action', 'ad-code-manager' ) ); } $method = isset( $_REQUEST['method'] ) ? sanitize_text_field( $_REQUEST['method'] ) : ''; $message = ''; // Depending on the method we're performing, sanitize the requisite data and do it. switch ( $method ) { case 'add': case 'edit': $id = ( isset( $_REQUEST['id'] ) ) ? absint( $_REQUEST['id'] ) : 0; $priority = ( isset( $_REQUEST['priority'] ) ) ? absint( $_REQUEST['priority'] ) : 10; $operator = ( isset( $_REQUEST['operator'] ) && in_array( $_REQUEST['operator'], array( 'AND', 'OR' ) ) ) ? sanitize_text_field( $_REQUEST['operator'] ) : $this->logical_operator; $ad_code_vals = array( 'priority' => $priority, 'operator' => $operator, ); foreach ( $this->current_provider->ad_code_args as $arg ) { $ad_code_vals[ $arg['key'] ] = sanitize_text_field( $_REQUEST['acm-column'][ $arg['key'] ] ?? '' ); } /** * Filter to validate ad code data before saving. * * Providers can hook into this filter to perform custom validation. * Return a WP_Error to prevent saving and display an error message. * * @since 0.8.0 * * @param true|WP_Error $valid True if valid, WP_Error if validation fails. * @param array $ad_code_vals The ad code values being saved. * @param int $id The ad code ID (0 for new ad codes). * @param string $method The method being performed ('add' or 'edit'). */ $validation_result = apply_filters( 'acm_validate_ad_code', true, $ad_code_vals, $id, $method ); if ( is_wp_error( $validation_result ) ) { if ( isset( $_REQUEST['doing_ajax'] ) && sanitize_text_field( $_REQUEST['doing_ajax'] ) ) { die( '
' . esc_html( $validation_result->get_error_message() ) . '
' ); } // Store the error message in a transient for display after redirect. set_transient( 'acm_validation_error_' . get_current_user_id(), $validation_result->get_error_message(), 30 ); $message = 'validation-error'; break; } if ( 'add' === $method ) { $id = $this->create_ad_code( $ad_code_vals ); } else { $id = $this->edit_ad_code( $id, $ad_code_vals ); } if ( is_wp_error( $id ) ) { // We can die with an error if this is an edit/ajax request if ( isset( $id->errors['edit-error'][0] ) ) { die( '
' . esc_html( $id->errors['edit-error'][0] ) . '
' ); } else { $message = 'error-adding-editing-ad-code'; } break; } $new_conditionals = array(); $unsafe_conditionals = $_REQUEST['acm-conditionals'] ?? array(); foreach ( $unsafe_conditionals as $index => $unsafe_conditional ) { $index = (int) $index; $arguments = ( isset( $_REQUEST['acm-arguments'][ $index ] ) ) ? sanitize_text_field( $_REQUEST['acm-arguments'][ $index ] ) : ''; $conditional = array( 'function' => sanitize_key( $unsafe_conditional ), 'arguments' => $arguments, ); if ( ! empty( $conditional['function'] ) ) { $new_conditionals[] = $conditional; } } if ( 'add' === $method ) { foreach ( $new_conditionals as $new_conditional ) { $this->create_conditional( $id, $new_conditional ); } $message = 'ad-code-added'; } else { $this->edit_conditionals( $id, $new_conditionals ); $message = 'ad-code-updated'; } $this->flush_cache(); break; case 'delete': $id = absint( $_REQUEST['id'] ); $this->delete_ad_code( $id ); $this->flush_cache(); $message = 'ad-code-deleted'; break; case 'update_options': $options = $this->get_options(); foreach ( $options as $key => $value ) { if ( isset( $_REQUEST[ $key ] ) ) { $options[ $key ] = sanitize_text_field( $_REQUEST[ $key ] ); } } $this->update_options( $options ); $message = 'options-saved'; break; } if ( isset( $_REQUEST['doing_ajax'] ) && sanitize_text_field( $_REQUEST['doing_ajax'] ) ) { if ( 'edit' === $method ) { set_current_screen( 'ad-code-manager' ); $table_class = $this->providers->{$this->current_provider_slug}['table']; $this->wp_list_table = new $table_class(); $this->wp_list_table->prepare_items(); $new_ad_code = $this->get_ad_code( $id ); echo $this->wp_list_table->single_row( $new_ad_code ); } } else { // @todo support ajax and non-ajax requests $redirect_url = add_query_arg( 'message', $message, remove_query_arg( 'message', wp_get_referer() ) ); wp_safe_redirect( $redirect_url ); exit(); } exit; } /** * Get the ad codes stored in our custom post type */ function get_ad_codes( $query_args = array() ) { /** * Filters the allowed query parameters for retrieving ad codes. * * Controls which query parameters can be passed to modify the ad codes query. * * @since 0.1 * * @param array $allowed_params Array of allowed query parameter names. Default array( 'offset' ). */ $allowed_query_params = apply_filters( 'acm_allowed_get_posts_args', array( 'offset' ) ); /** * Filters the maximum number of ad codes to retrieve. * * By default, queries are limited to 50 ad codes. * Use this filter to increase or decrease the limit. * * @since 0.1 * * @param int $count Maximum number of ad codes to retrieve. Default 50. */ $args = array( 'post_type' => $this->post_type, 'numberposts' => apply_filters( 'acm_ad_code_count', 50 ), ); foreach ( (array) $query_args as $query_key => $query_value ) { if ( ! in_array( $query_key, $allowed_query_params, true ) ) { unset( $query_args[ $query_key ] ); } else { $args[ $query_key ] = $query_value; } } $ad_codes_formatted = wp_cache_get( 'ad_codes', 'acm' ); if ( false === $ad_codes_formatted ) { // Store an empty array when no ad codes exist so this block doesn't run on each page load $ad_codes_formatted = array(); $ad_codes = get_posts( $args ); foreach ( $ad_codes as $ad_code_cpt ) { $provider_url_vars = array(); foreach ( $this->current_provider->ad_code_args as $arg ) { $provider_url_vars[ $arg['key'] ] = get_post_meta( $ad_code_cpt->ID, $arg['key'], true ); } $priority = get_post_meta( $ad_code_cpt->ID, 'priority', true ); $priority = ( ! empty( $priority ) ) ? (int) $priority : 10; $operator = get_post_meta( $ad_code_cpt->ID, 'operator', true ); $operator = ( ! empty( $operator ) ) ? esc_html( $operator ) : $this->logical_operator; $ad_codes_formatted[] = array( 'conditionals' => $this->get_conditionals( $ad_code_cpt->ID ), 'url_vars' => $provider_url_vars, 'priority' => $priority, 'operator' => $operator, 'post_id' => $ad_code_cpt->ID, ); } wp_cache_add( 'ad_codes', $ad_codes_formatted, 'acm', 3600 ); } return $ad_codes_formatted; } /** * Get a single ad code * * @param int $post_id Post ID for the ad code that we want * @return array|bool $ad_code Ad code representation of the data */ function get_ad_code( $post_id ) { $post = get_post( $post_id ); if ( ! $post ) { return false; } $provider_url_vars = array(); foreach ( $this->current_provider->ad_code_args as $arg ) { $provider_url_vars[ $arg['key'] ] = get_post_meta( $post->ID, $arg['key'], true ); } $priority = get_post_meta( $post_id, 'priority', true ); $priority = ( ! empty( $priority ) ) ? (int) $priority : 10; $operator = get_post_meta( $post_id, 'operator', true ); $operator = ( ! empty( $operator ) ) ? esc_html( $operator ) : $this->logical_operator; return array( 'conditionals' => $this->get_conditionals( $post->ID ), 'url_vars' => $provider_url_vars, 'priority' => $priority, 'operator' => $operator, 'post_id' => $post->ID, ); } /** * Flush cache */ function flush_cache() { wp_cache_delete( 'ad_codes', 'acm' ); } /** * Get the conditional values for an ad code */ function get_conditionals( $ad_code_id ) { $conditionals = get_post_meta( $ad_code_id, 'conditionals', true ); if ( empty( $conditionals ) ) { $conditionals = array(); } return $conditionals; } /** * Create a new ad code in the database * * @uses register_ad_code() * * @param array $ad_code * @return int|false|WP_Error post_id or false */ function create_ad_code( $ad_code = array() ) { $titles = array(); foreach ( $this->current_provider->ad_code_args as $arg ) { // We shouldn't create an ad code if any of required fields are not set. if ( ! isset( $ad_code[ $arg['key'] ] ) && $arg['required'] === true ) { return new WP_Error(); } $titles[] = $ad_code[ $arg['key'] ]; } $acm_post = array( 'post_title' => implode( '-', $titles ), 'post_status' => 'publish', 'comment_status' => 'closed', 'ping_status' => 'closed', 'post_type' => $this->post_type, ); if ( ! is_wp_error( $acm_inserted_post_id = wp_insert_post( $acm_post, true ) ) ) { foreach ( $this->current_provider->ad_code_args as $arg ) { update_post_meta( $acm_inserted_post_id, $arg['key'], $ad_code[ $arg['key'] ] ); } update_post_meta( $acm_inserted_post_id, 'priority', $ad_code['priority'] ); update_post_meta( $acm_inserted_post_id, 'operator', $ad_code['operator'] ); $this->flush_cache(); return $acm_inserted_post_id; } return false; } /** * Update an existing ad code */ function edit_ad_code( $ad_code_id, $ad_code = array() ) { foreach ( $this->current_provider->ad_code_args as $arg ) { // If a required argument is not set, we return an error message with the missing parameter if ( ! isset( $ad_code[ $arg['key'] ] ) && $arg['required'] === true ) { return new WP_Error( 'edit-error', 'Error updating ad code, a parameter for ' . esc_html( $arg['key'] ) . ' is required.' ); } } if ( 0 !== $ad_code_id ) { foreach ( $this->current_provider->ad_code_args as $arg ) { update_post_meta( $ad_code_id, $arg['key'], $ad_code[ $arg['key'] ] ); } update_post_meta( $ad_code_id, 'priority', $ad_code['priority'] ); update_post_meta( $ad_code_id, 'operator', $ad_code['operator'] ); } $this->flush_cache(); return $ad_code_id; } /** * Delete an existing ad code */ function delete_ad_code( $ad_code_id ) { if ( 0 !== $ad_code_id && $this->post_type === get_post_type( $ad_code_id ) ) { wp_delete_post( $ad_code_id, true ); // Force delete post. $this->flush_cache(); return true; } } /** * Create conditional. * * @param int $ad_code_id ID of our CPT post. * @param array $conditional Conditional to add. * @return bool */ function create_conditional( $ad_code_id, $conditional ) { if ( 0 !== $ad_code_id && ! empty( $conditional ) ) { $existing_conditionals = get_post_meta( $ad_code_id, 'conditionals', true ); if ( ! is_array( $existing_conditionals ) ) { $existing_conditionals = array(); } $existing_conditionals[] = array( 'function' => $conditional['function'], 'arguments' => explode( ';', $conditional['arguments'] ), ); return update_post_meta( $ad_code_id, 'conditionals', $existing_conditionals ); } return false; } /** * Update all conditionals for ad code. * * @since 0.2.0 * * @param int $ad_code_id ID of our CPT post. * @param array $conditionals Conditionals to edit. * @return bool */ function edit_conditionals( $ad_code_id, $conditionals = array() ) { if ( 0 !== $ad_code_id && ! empty( $conditionals ) ) { $new_conditionals = array(); foreach ( $conditionals as $conditional ) { if ( '' == $conditional['function'] ) { continue; } $new_conditionals[] = array( 'function' => $conditional['function'], 'arguments' => (array) $conditional['arguments'], ); } return update_post_meta( $ad_code_id, 'conditionals', $new_conditionals ); } elseif ( 0 !== $ad_code_id ) { return update_post_meta( $ad_code_id, 'conditionals', array() ); } } /** * Hook in our submenu page to the navigation */ public function action_admin_menu() { $hook = add_options_page( __( 'Ad Code Manager', 'ad-code-manager' ), __( 'Ad Code Manager', 'ad-code-manager' ), $this->manage_ads_cap, $this->plugin_slug, array( $this, 'admin_view_controller' ) ); add_action( 'load-' . $hook, array( $this, 'action_load_ad_code_manager' ) ); } /** * Instantiate the List Table and handle our bulk actions on the load of the page. * * @since 0.2.2 */ function action_load_ad_code_manager() { // Instantiate this list table $table_class = $this->providers->{$this->current_provider_slug}['table']; $this->wp_list_table = new $table_class(); // Handle any bulk action requests if ( 'delete' === $this->wp_list_table->current_action() ) { check_admin_referer( 'acm-bulk-action', 'bulk-action-nonce' ); $ad_code_ids = array_map( 'intval', $_REQUEST['ad-codes'] ?? array() ); foreach ( $ad_code_ids as $ad_code_id ) { $this->delete_ad_code( $ad_code_id ); } $redirect_url = add_query_arg( 'message', 'ad-codes-deleted', remove_query_arg( 'message', wp_get_referer() ) ); wp_safe_redirect( $redirect_url ); exit; } } /** * Print the admin interface for managing the ad codes. */ function admin_view_controller() { // Check for edit action. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verified in render_edit_page. if ( isset( $_GET['action'] ) && 'edit' === $_GET['action'] && isset( $_GET['id'] ) ) { $this->render_edit_page(); return; } require_once dirname( AD_CODE_MANAGER_FILE ) . '/views/ad-code-manager.tpl.php'; } /** * Render the dedicated edit page for an ad code. * * @since 0.10.0 */ function render_edit_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This is a view, nonce verified on form submission. $ad_code_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; $ad_code = $this->get_ad_code( $ad_code_id ); // Handle invalid ID gracefully. if ( ! $ad_code ) { wp_die( esc_html__( 'Invalid ad code ID.', 'ad-code-manager' ), esc_html__( 'Error', 'ad-code-manager' ), array( 'back_link' => true ) ); } require_once dirname( AD_CODE_MANAGER_FILE ) . '/views/edit-ad-code.tpl.php'; } /** * Register a custom widget to display ad zones */ function register_widget() { register_widget( 'ACM_Ad_Zones' ); } /** * Register scripts and styles */ function register_scripts_and_styles() { global $pagenow; // Only load this on the proper page if ( 'options-general.php' !== $pagenow || ! isset( $_GET['page'] ) || $_GET['page'] != $this->plugin_slug ) { return; } wp_enqueue_style( 'acm-style', plugins_url( '/', AD_CODE_MANAGER_FILE ) . '/acm.css', array(), AD_CODE_MANAGER_VERSION ); } /** * Register an ad tag with the plugin so it can be used * on the frontend of the site * * @since 0.1 * * @param string $tag Ad tag for this instance of code * @param string $url Script URL for ad code * @param array $conditionals WordPress-style conditionals for where this code should be displayed * @param int $priority What priority this registration runs at * @param array $url_vars Replace tokens in $script with these values * @param int $priority Priority of the ad code in comparison to others * @return bool|WP_Error $success Whether we were successful in registering the ad tag */ function register_ad_code( $tag, $url, $conditionals = array(), $url_vars = array(), $priority = 10, $operator = false ) { // Run $url aganist a whitelist to make sure it's a safe URL if ( ! $this->validate_script_url( $url ) ) { return; } // @todo Sanitize the conditionals against our possible set of conditionals so that users // can't just run arbitrary functions. These are whitelisted on execution of the ad code so we're fine for now // @todo Sanitize all of the other input // Make sure our priority is an integer if ( ! is_int( $priority ) ) { $priority = 10; } // Make sure our operator is 'OR' or 'AND' if ( ! $operator || ! in_array( $operator, array( 'AND', 'OR' ) ) ) { $operator = $this->logical_operator; } // Save the ad code to our set of ad codes $this->ad_codes[ $tag ][] = array( 'url' => $url, 'priority' => $priority, 'operator' => $operator, 'conditionals' => $conditionals, 'url_vars' => $url_vars, ); } /** * Register an array of ad tags with the plugin * * @since 0.1 * * @param array $ad_codes An array of ad tags */ function register_ad_codes( $ad_codes = array() ) { if ( empty( $ad_codes ) ) { return; } foreach ( (array) $ad_codes as $key => $ad_code ) { $default = array( 'tag' => '', 'url' => '', 'conditionals' => array(), 'url_vars' => array(), 'priority' => 10, 'operator' => $this->logical_operator, ); $ad_code = array_merge( $default, $ad_code ); foreach ( (array) $this->ad_tag_ids as $default_tag ) { /** * 'enable_ui_mapping' is a special argument which means this ad tag can be * mapped with ad codes through the admin interface. If that's the case, we * want to make sure those ad codes are only registered with the tag. */ if ( isset( $default_tag['enable_ui_mapping'] ) && $default_tag['tag'] != $ad_code['url_vars']['tag'] ) { continue; } $ad_code['priority'] = $ad_code['priority'] === '' ? 10 : (int) $ad_code['priority']; // make sure priority is int, if it's unset, we set it to 10 // Make sure our operator is 'OR' or 'AND' if ( ! $ad_code['operator'] || ! in_array( $ad_code['operator'], array( 'AND', 'OR' ) ) ) { $operator = $this->logical_operator; } /** * Filters the default URL for an ad code. * * If you don't specify a URL for your ad code when registering it in * the WordPress admin or at a code level, you can apply it with this filter. * * @since 0.1 * * @param string $url The ad code URL. May be empty. */ $this->register_ad_code( $default_tag['tag'], apply_filters( 'acm_default_url', $ad_code['url'] ), $ad_code['conditionals'], array_merge( $default_tag['url_vars'], $ad_code['url_vars'] ), $ad_code['priority'], $ad_code['operator'] ); } } } /** * Return the ad code for an ad ID. * * @since 0.6.0 * * @param string $tag_id Unique ID for the ad tag * @return string The ad code. May be an empty string. */ function get_acm_tag( $tag_id ): string { /** * Filters whether to disable ad rendering. * * By default, ads are disabled on post previews to prevent ad tracking issues. * * @since 0.1 * * @param bool $disable Whether to disable ad rendering. Default is the result of is_preview(). */ if ( apply_filters( 'acm_disable_ad_rendering', is_preview() ) ) { return ''; } $code_to_display = $this->get_matching_ad_code( $tag_id ); // get_matching_ad_code can return an empty value. if ( empty( $code_to_display ) ) { return ''; } // Run $url against a safelist to make sure it's a safe URL. if ( ! $this->validate_script_url( $code_to_display['url'] ) ) { return ''; } /** * Filters the output HTML template for an ad tag. * * Support multiple ad formats (e.g., JavaScript ad tags, or simple HTML tags) * by adjusting the HTML rendered for a given ad tag. * * @since 0.1 * * @param string $output_html The HTML template with tokens (e.g., '%url%', '%width%'). * @param string $tag_id The ad tag ID being rendered. */ $output_html = apply_filters( 'acm_output_html', $this->current_provider->output_html, $tag_id ); // Parse the output and replace any tokens we have left. But first, load the script URL. $output_html = str_replace( '%url%', $code_to_display['url'], $output_html ); /** * Filters the output tokens for an ad tag. * * Tokens are placeholders in the output HTML that get replaced with actual values. * Register custom tokens depending on your setup. * * @since 0.1 * * @param array $output_tokens Associative array of tokens and their replacement values. * Keys should include % (e.g., '%width%' => '300'). * @param string $tag_id The ad tag ID being rendered. * @param array $code_to_display The matching ad code configuration. */ $output_tokens = apply_filters( 'acm_output_tokens', $this->current_provider->output_tokens, $tag_id, $code_to_display ); foreach ( (array) $output_tokens as $token => $val ) { $output_html = str_replace( $token, esc_attr( $val ), $output_html ); } /** * Filters the output HTML after token replacement. * * Use this filter for final modifications to the ad HTML after all tokens * have been processed. Useful for adding wrappers or final adjustments. * * @since 0.2 * * @param string $output_html The processed HTML with all tokens replaced. * @param string $tag_id The ad tag ID being rendered. */ $output_html = apply_filters( 'acm_output_html_after_tokens_processed', $output_html, $tag_id ); /** * Configuration filter: acm_wrapper_classes * Filter the CSS classes applied to the ad wrapper div. * * @since 0.8.0 * * @param array $classes Array of CSS class names. Default: 'acm-wrapper', 'acm-tag-{tag_id}'. * @param string $tag_id The ad tag ID being rendered. */ $wrapper_classes = apply_filters( 'acm_wrapper_classes', array( 'acm-wrapper', 'acm-tag-' . sanitize_html_class( $tag_id ) ), $tag_id ); // Allow disabling the wrapper by returning an empty array. if ( ! empty( $wrapper_classes ) && is_array( $wrapper_classes ) ) { $output_html = sprintf( '
%s
', esc_attr( implode( ' ', array_map( 'sanitize_html_class', $wrapper_classes ) ) ), $output_html ); } return $output_html; } /** * Display the ad code for an ad ID. * * Example: * do_action( 'acm_tag, 'your_tag_id' ) * * @param string $tag_id Unique ID for the ad tag. */ public function action_acm_tag( $tag_id ): void { echo $this->get_acm_tag( $tag_id ); } /** * Of all the ad codes registered, get the one that matches our current context * * @since 0.4 */ public function get_matching_ad_code( $tag_id ) { global $wp_query; static $checked_ad_codes = array(); // If there aren't any ad codes, it's not worth it for us to do anything. if ( ! isset( $this->ad_codes[ $tag_id ] ) ) { return; } // This method might be expensive when there's a lot of ad codes // So instead of executing over and over again, return cached matching ad code. $cache_key = "acm:{$tag_id}:" . md5( serialize( $wp_query->query_vars ) ); /** * Filters the cache expiration time for matching ad codes. * * Controls how long a matched ad code is cached to improve performance. * Return false to disable object caching (request-level caching only). * * @since 0.4 * * @param int|false $cache_expiration Cache time in seconds. Default 600 (10 minutes). * Return false to disable object caching. * @param string $tag_id The tag ID being matched. */ $cache_expiration = apply_filters( 'acm_matching_ad_code_cache_expiration', 600, $tag_id ); if ( false === $cache_expiration ) { $ad_code = isset( $checked_ad_codes[ $cache_key ] ) ? $checked_ad_codes[ $cache_key ] : false; } else { $ad_code = wp_cache_get( $cache_key, 'acm' ); } if ( false !== $ad_code ) { return $ad_code; } /** * Filters whether to reset post data before matching ad codes. * * Most conditionals check against the global $post. When matching ad codes * inside a loop, this can result in incorrect matches. Enable this filter * to call wp_reset_postdata() before evaluation. * * @since 0.4 * * @param bool $reset Whether to reset post data. Default false for backwards compatibility. */ if ( apply_filters( 'acm_reset_postdata_before_match', false ) ) { wp_reset_postdata(); } // Run our ad codes through all of the conditionals to make sure we should // be displaying it $display_codes = array(); foreach ( (array) $this->ad_codes[ $tag_id ] as $ad_code ) { /** * Filters whether to display ad codes without conditionals. * * By default, ad codes without conditionals are not displayed. * Enable this filter to show ad codes on all pages when they have * no conditional restrictions. * * @since 0.1 * * @param bool $display Whether to display ad codes without conditionals. Default false. */ // If the ad code doesn't have any conditionals // we should add it to the display list if ( empty( $ad_code['conditionals'] ) && apply_filters( 'acm_display_ad_codes_without_conditionals', false ) ) { $display_codes[] = $ad_code; continue; } // If the ad code doesn't have any conditionals // and configuration filter acm_display_ad_codes_without_conditionals returns false // We should should skip it if ( empty( $ad_code['conditionals'] ) && ! apply_filters( 'acm_display_ad_codes_without_conditionals', false ) ) { continue; } $include = true; foreach ( $ad_code['conditionals'] as $conditional ) { // If the conditional was passed as an array, then we have a complex rule. // Otherwise, we have a function name and expect rule. if ( is_array( $conditional ) ) { $cond_func = $conditional['function']; if ( ! empty( $conditional['arguments'] ) ) { $cond_args = $conditional['arguments']; } else { $cond_args = array(); } $cond_result = $conditional['result'] ?? true; } else { $cond_func = $conditional; $cond_args = array(); $cond_result = true; } // Special trick: include 'not_' in front of the function name to reverse the result. if ( 0 === strpos( $cond_func, 'not_' ) ) { $cond_func = ltrim( $cond_func, 'not_' ); $cond_result = false; } // Don't run the conditional if the conditional function doesn't exist or isn't in our safelist. if ( ! is_callable( $cond_func ) || ! in_array( $cond_func, $this->whitelisted_conditionals ) ) { continue; } // Run our conditional and use any arguments that were passed if ( ! empty( $cond_args ) ) { /** * Filters the arguments passed to conditional functions. * * For certain conditionals like has_tag or has_category, you might need * to modify the arguments passed to the function. * * @since 0.1 * * @param array $cond_args Array of arguments to pass to the conditional function. * @param string $cond_func The conditional function name being called. */ $result = call_user_func_array( $cond_func, apply_filters( 'acm_conditional_args', $cond_args, $cond_func ) ); } else { $result = $cond_func(); } // If our results don't match what we need, don't include this ad code if ( $cond_result !== $result ) { $include = false; } else { $include = true; } // If we have matching conditional and $ad_code['operator'] equals OR just break from the loop and do not try to evaluate others if ( $include && 'OR' === $ad_code['operator'] ) { break; } // If $ad_code['operator'] equals AND and one conditional evaluates false, skip this ad code if ( ! $include && 'AND' === $ad_code['operator'] ) { break; } } // If we're supposed to include the ad code even after we've run the conditionals, // let's do it if ( $include ) { $display_codes[] = $ad_code; } } // Don't do anything if we've ended up with no ad codes if ( empty( $display_codes ) ) { return; } // Prioritize the display of the ad codes based on // the priority argument for the ad code $prioritized_display_codes = array(); foreach ( $display_codes as $display_code ) { $priority = $display_code['priority']; $prioritized_display_codes[ $priority ][] = $display_code; } ksort( $prioritized_display_codes, SORT_NUMERIC ); $shifted_prioritized_display_codes = array_shift( $prioritized_display_codes ); $code_to_display = array_shift( $shifted_prioritized_display_codes ); if ( false === $cache_expiration ) { $checked_ad_codes[ $cache_key ] = $code_to_display; } else { wp_cache_add( $cache_key, $code_to_display, 'acm', $cache_expiration ); } return $code_to_display; } /** * Filter the output tokens used in $this->action_acm_tag to include our URL vars * * @since 0.1 * * @return array $output Placeholder tokens to be replaced with their values */ function filter_output_tokens( $output_tokens, $tag_id, $code_to_display ) { if ( ! isset( $code_to_display['url_vars'] ) || ! is_array( $code_to_display['url_vars'] ) ) { return $output_tokens; } foreach ( $code_to_display['url_vars'] as $url_var => $val ) { $new_key = '%' . $url_var . '%'; $output_tokens[ $new_key ] = $val; } return $output_tokens; } /** * Ensure the URL being used passes our whitelist check * * @since 0.1 * @see https://gist.github.com/1623788 */ function validate_script_url( $url ) { // If url is empty, there's nothing to validate // Fixes issue with DFP JS if ( empty( $url ) ) { return true; } $domain = wp_parse_url( $url, PHP_URL_HOST ); // Check if we match the domain exactly if ( in_array( $domain, $this->current_provider->whitelisted_script_urls, true ) ) { return true; } $valid = false; foreach ( $this->current_provider->whitelisted_script_urls as $whitelisted_domain ) { $whitelisted_domain = '.' . $whitelisted_domain; // Prevent things like 'evilsitetime.com' if ( strpos( $domain, $whitelisted_domain ) === ( strlen( $domain ) - strlen( $whitelisted_domain ) ) ) { $valid = true; break; } } return $valid; } /** * Shortcode function * * @since 0.2 * * @return string HTML output. May be an empty string if no ad code is found. */ function shortcode( $atts ): string { $atts = shortcode_atts( array( 'id' => '', ), $atts ); $id = sanitize_text_field( $atts['id'] ); if ( empty( $id ) ) { return ''; } return $this->get_acm_tag( $id ); } }