'yes',
'url_patterns' => '',
'categories' => array(),
'tags' => array(),
));
// All pages option is enabled
if (isset($page_rules['all_pages']) && $page_rules['all_pages'] === 'yes') {
return true;
}
// Check URL patterns
if (!empty($page_rules['url_patterns'])) {
$current_url = isset($_SERVER['REQUEST_URI']) ? esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'])) : '';
if (empty($current_url)) {
return false;
}
$patterns = explode("\n", $page_rules['url_patterns']);
foreach ($patterns as $pattern) {
$pattern = trim($pattern);
if (empty($pattern)) {
continue;
}
// Convert wildcard pattern to regex
$regex = str_replace(
array('\*', '/'),
array('.*', '\/'),
preg_quote($pattern, '/')
);
if (preg_match('/^' . $regex . '/i', $current_url)) {
return true;
}
}
}
// Check categories and tags
if (is_singular()) {
global $post;
// Check categories
if (!empty($page_rules['categories'])) {
$post_categories = wp_get_post_categories($post->ID);
foreach ($post_categories as $category) {
if (in_array($category, $page_rules['categories'])) {
return true;
}
}
}
// Check tags
if (!empty($page_rules['tags'])) {
$post_tags = wp_get_post_tags($post->ID, array('fields' => 'ids'));
foreach ($post_tags as $tag) {
if (in_array($tag, $page_rules['tags'])) {
return true;
}
}
}
}
// Check category archive pages
if (is_category() && !empty($page_rules['categories'])) {
$category = get_queried_object_id();
if (in_array($category, $page_rules['categories'])) {
return true;
}
}
// Check tag archive pages
if (is_tag() && !empty($page_rules['tags'])) {
$tag = get_queried_object_id();
if (in_array($tag, $page_rules['tags'])) {
return true;
}
}
return false;
}
/**
* Get the script sources from API with caching
*/
private function get_script_sources()
{
$cache_key = 'ad_unblock_script_sources_' . get_current_blog_id();
$error_cache_key = $cache_key . '_error';
// Get cached data and check if it's valid
$cached_data = get_transient($cache_key);
$is_cache_valid = $this->is_cache_data_valid($cached_data);
$is_in_error_state = $this->is_in_error_state($error_cache_key);
// Return valid cache if not in error state
if ($is_cache_valid && !$is_in_error_state) {
return $cached_data['data'];
}
// Try to fetch fresh data from API
$fresh_data = $this->fetch_script_sources_from_api();
// If API call succeeded, update cache and return fresh data
if (false !== $fresh_data) {
$this->refresh_script_sources_cache($cache_key, $error_cache_key, $fresh_data);
return $fresh_data;
}
// API failed - set error state
$this->set_error_state($error_cache_key);
// Return stale cache if available, otherwise empty array
if ($cached_data && isset($cached_data['data']) && is_array($cached_data['data'])) {
return $cached_data['data'];
}
return array();
}
/**
* Check if cached data is valid and not expired
*/
private function is_cache_data_valid($cached_data)
{
return false !== $cached_data &&
is_array($cached_data) &&
isset($cached_data['timestamp'], $cached_data['data']) &&
is_array($cached_data['data']) &&
!empty($cached_data['data']) &&
$this->cache_matches_request($cached_data) &&
(time() - $cached_data['timestamp']) < self::CACHE_DURATION;
}
/**
* Check that cached data was fetched for the request we would make now
*
* A changed domain, endpoint or site address has to re-fetch rather than
* keep serving another request's script list. Entries written before the
* domain parameter existed carry no request URL and so never match, which
* refreshes them once on upgrade.
*/
private function cache_matches_request($cached_data)
{
$cached_url = isset($cached_data['request_url']) ? $cached_data['request_url'] : '';
return $cached_url === $this->get_api_request_url();
}
/**
* Check if we're currently in an error state
*/
private function is_in_error_state($error_cache_key)
{
$error_data = get_transient($error_cache_key);
if (false === $error_data || !is_array($error_data) || !isset($error_data['timestamp'])) {
return false;
}
// Consider error state active for 60 seconds
return (time() - $error_data['timestamp']) < 60;
}
/**
* Reduce any host-bearing string to a bare hostname
*
* Mirrors the normalization the API applies to the domain it receives:
* lowercase, no scheme, userinfo, port, path or trailing dot. Anything
* that is not left looking like a hostname returns an empty string.
*/
private function normalize_domain($value)
{
if (!is_string($value)) {
return '';
}
$host = strtolower(trim($value));
if ('' === $host) {
return '';
}
$scheme_end = strpos($host, '://');
if (false !== $scheme_end) {
$host = substr($host, $scheme_end + 3);
}
// Path, query and fragment.
$host = preg_split('#[/?\#]#', $host)[0];
// Userinfo, then port.
$at = strrpos($host, '@');
if (false !== $at) {
$host = substr($host, $at + 1);
}
$colon = strpos($host, ':');
if (false !== $colon) {
$host = substr($host, 0, $colon);
}
$host = rtrim($host, '.');
// An internationalized domain has to travel as punycode, because that
// is the form the API's per-domain lists are keyed on.
if (preg_match('/[^\x20-\x7e]/', $host) && function_exists('idn_to_ascii')) {
$ascii = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
if (is_string($ascii) && '' !== $ascii) {
$host = strtolower($ascii);
}
}
return preg_match('/^[a-z0-9][a-z0-9.-]*$/', $host) ? $host : '';
}
/**
* The domain of this WordPress site, from its configured site address
*/
private function get_default_site_domain()
{
$domain = $this->normalize_domain(wp_parse_url(home_url(), PHP_URL_HOST));
if ('' === $domain && isset($_SERVER['HTTP_HOST'])) {
$domain = $this->normalize_domain(sanitize_text_field(wp_unslash($_SERVER['HTTP_HOST'])));
}
return $domain;
}
/**
* The domain sent to the API, configured or auto-detected
*/
private function get_site_domain()
{
$configured = $this->normalize_domain(get_option('ad_unblock_site_domain', ''));
return '' !== $configured ? $configured : $this->get_default_site_domain();
}
/**
* Get the API endpoint to use (custom or default)
*/
private function get_api_endpoint()
{
$custom_endpoint = get_option('ad_unblock_custom_api_endpoint', '');
return !empty($custom_endpoint) ? $custom_endpoint : self::API_ENDPOINT;
}
/**
* Build the API request URL for this site
*
* The `domain` parameter tells the service which site is asking, so it can
* answer with a script host reserved for this domain when a filter list has
* blocked the shared one for it. A request without the parameter still gets
* the default list, so an empty domain is not an error.
*/
private function get_api_request_url()
{
$api_endpoint = $this->get_api_endpoint();
$domain = $this->get_site_domain();
if ('' === $domain) {
return $api_endpoint;
}
// Passed unencoded: normalize_domain() has already restricted the value
// to characters that are literal in a query string, and add_query_arg()
// does not encode what it is given.
return add_query_arg('domain', $domain, $api_endpoint);
}
/**
* Fetch script sources from API
*/
private function fetch_script_sources_from_api()
{
$response = wp_remote_get($this->get_api_request_url(), array('timeout' => 10));
if (is_wp_error($response) || 200 !== wp_remote_retrieve_response_code($response)) {
return false;
}
$body = wp_remote_retrieve_body($response);
if (empty($body)) {
return false;
}
$script_sources = json_decode($body, true);
// Validate JSON structure and content
if (!is_array($script_sources) || empty($script_sources)) {
return false;
}
// Additional validation: ensure all items are strings (URLs)
foreach ($script_sources as $source) {
if (!is_string($source) || empty($source)) {
return false;
}
}
return $script_sources;
}
/**
* Refresh script sources cache with fresh data and clear error state
*/
private function refresh_script_sources_cache($cache_key, $error_cache_key, $script_sources)
{
// Clear error state since we have fresh data
delete_transient($error_cache_key);
// Store fresh data with timestamp
$cache_data = array(
'data' => $script_sources,
'timestamp' => time(),
'request_url' => $this->get_api_request_url()
);
set_transient($cache_key, $cache_data, WEEK_IN_SECONDS);
}
/**
* Set error state with timestamp
*/
private function set_error_state($error_cache_key)
{
$error_data = array(
'timestamp' => time()
);
set_transient($error_cache_key, $error_data, HOUR_IN_SECONDS);
}
/**
* Insert the script into the page head
*/
public function insert_script()
{
$verification_code = get_option('ad_unblock_verification_code', '');
if (empty($verification_code) || !$this->should_enable_on_current_page()) {
return;
}
$script_sources = $this->get_script_sources();
if (empty($script_sources) || !is_array($script_sources)) {
return;
}
// Get the first script source only
$script_url = reset($script_sources);
// Get cache status and full cached data for meta tag
$cache_status = $this->get_cache_status();
$cache_key = 'ad_unblock_script_sources_' . get_current_blog_id();
$cached_data = get_transient($cache_key);
// Prepare cache details for meta tag
$cache_details = array(
'verification' => $verification_code,
'domain' => $this->get_site_domain(),
'cache_active' => $cache_status['has_cache'] ? 'true' : 'false',
'error_state' => $cache_status['has_error'] ? 'true' : 'false',
'sources_count' => $cache_status['cache_data_count'],
'expires_in' => $cache_status['time_until_expiry'],
'timestamp' => time(),
'cached_sources' => $script_sources,
'cache_timestamp' => (false !== $cached_data && is_array($cached_data) && isset($cached_data['timestamp'])) ? $cached_data['timestamp'] : null
);
// Add verification code and cache details as a meta tag in DOM
echo '' . "\n";
echo '' . "\n";
// Printed directly rather than enqueued, for three reasons:
//
// 1. wp_enqueue_script() cannot emit a valueless attribute, and it
// always builds the tag with `src` first — Cloudflare only honours
// `data-cfasync` when it appears BEFORE `src`.
// 2. Enqueued head scripts print at wp_head priority 9, after styles.
// This runs at priority 0, so recovery loads ahead of the ad code.
// 3. The `strategy` argument is WP 6.3+; on older WP that array is a
// truthy $in_footer and the tag would land in the footer.
//
// The two attributes opt the tag out of Cloudflare Rocket Loader and
// WP Rocket's Delay JavaScript Execution, both of which would defer or
// rewrite it. Neither covers WP Rocket's separate "Load JavaScript
// deferred" option — that one has no attribute, and needs the script
// URL in its Excluded JavaScript Files list.
// phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript
echo '' . "\n";
}
/**
* Enqueue admin styles and scripts
*/
public function enqueue_admin_assets($hook)
{
if ('settings_page_ad-unblock' !== $hook) {
return;
}
wp_enqueue_style(
'ad-unblock-admin',
plugin_dir_url(__FILE__) . 'assets/css/admin.css',
array(),
AD_UNBLOCK_VERSION
);
// Enqueue Select2
wp_enqueue_style(
'ad-unblock-select2-css',
plugin_dir_url(__FILE__) . 'assets/css/admin-select2.css'
);
wp_enqueue_script(
'ad-unblock-select2-js',
'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js',
array('jquery'),
null,
true
);
wp_enqueue_style(
'select2-css',
'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css'
);
wp_enqueue_script(
'ad-unblock-admin-js',
plugin_dir_url(__FILE__) . 'assets/js/admin.js',
array('jquery', 'ad-unblock-select2-js'),
AD_UNBLOCK_VERSION,
true
);
wp_localize_script('ad-unblock-admin-js', 'adUnblock', array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('ad-unblock-admin-nonce'),
'cache_status' => $this->get_cache_status()
));
}
/**
* AJAX handler to search for terms (categories, tags)
*/
public function ajax_search_terms()
{
check_ajax_referer('ad-unblock-admin-nonce', 'nonce');
$search = isset($_GET['q']) ? sanitize_text_field(wp_unslash($_GET['q'])) : '';
$taxonomy = isset($_GET['taxonomy']) ? sanitize_text_field(wp_unslash($_GET['taxonomy'])) : 'category';
if (!taxonomy_exists($taxonomy)) {
wp_send_json_error(array('message' => 'Invalid taxonomy.'));
}
$terms = get_terms(array(
'taxonomy' => $taxonomy,
'name__like' => $search,
'hide_empty' => false,
'number' => 20,
));
$results = array();
if (!is_wp_error($terms) && !empty($terms)) {
foreach ($terms as $term) {
$results[] = array(
'id' => $term->term_id,
'text' => $term->name,
);
}
}
wp_send_json_success($results);
}
/**
* AJAX handler to clear script cache
*/
public function ajax_clear_cache()
{
check_ajax_referer('ad-unblock-admin-nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Insufficient permissions.'));
}
$this->clear_script_cache();
wp_send_json_success(array(
'message' => 'Cache cleared successfully.',
'cache_status' => $this->get_cache_status()
));
}
/**
* AJAX handler to get current cache status
*/
public function ajax_get_cache_status()
{
check_ajax_referer('ad-unblock-admin-nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Insufficient permissions.'));
}
wp_send_json_success(array(
'cache_status' => $this->get_cache_status()
));
}
/**
* Clear the script cache
*/
public function clear_script_cache()
{
$cache_key = 'ad_unblock_script_sources_' . get_current_blog_id();
$error_cache_key = $cache_key . '_error';
delete_transient($cache_key);
delete_transient($error_cache_key);
}
/**
* Get cache status information
*/
private function get_cache_status()
{
$cache_key = 'ad_unblock_script_sources_' . get_current_blog_id();
$error_cache_key = $cache_key . '_error';
$cached_data = get_transient($cache_key);
$error_data = get_transient($error_cache_key);
$has_cache = false;
$cache_timestamp = null;
$cache_data_count = 0;
$time_until_expiry = 0;
$cache_expires = null;
// Check if we have valid cached data with timestamp
if (false !== $cached_data && is_array($cached_data) && isset($cached_data['timestamp'], $cached_data['data'])) {
$has_cache = true;
$cache_timestamp = $cached_data['timestamp'];
$cache_data_count = is_array($cached_data['data']) ? count($cached_data['data']) : 0;
$cache_expiry_time = $cache_timestamp + self::CACHE_DURATION;
$time_until_expiry = max(0, $cache_expiry_time - time());
$cache_expires = $cache_expiry_time;
}
// Check error state - only consider recent errors (within 60 seconds)
$has_error = false;
if (false !== $error_data && is_array($error_data) && isset($error_data['timestamp'])) {
$has_error = (time() - $error_data['timestamp']) < 60;
}
return array(
'has_cache' => $has_cache,
'has_error' => $has_error,
'cache_expires' => $cache_expires,
'cache_data_count' => $cache_data_count,
'time_until_expiry' => $time_until_expiry
);
}
/**
* Add settings link to plugin actions
*/
public function add_plugin_action_links($links)
{
$settings_link = '' . esc_html__('Settings', 'ad-unblock') . '';
array_unshift($links, $settings_link);
return $links;
}
/**
* Add additional links to plugin row meta
*/
public function add_plugin_row_meta($links, $file)
{
if (plugin_basename(__FILE__) === $file) {
$row_meta = array(
'docs' => '' . esc_html__('Documentation', 'ad-unblock') . '',
'support' => '' . esc_html__('Support', 'ad-unblock') . '',
);
return array_merge($links, $row_meta);
}
return $links;
}
}
// Initialize the plugin
Ad_Unblock::get_instance();