PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / trunk
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar vtrunk
3.3.1 3.3.0 3.2.14 3.2.13 3.2.12 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 trunk 0.2.5.5 0.2.5.6 0.2.5.7 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 All 156 releases
← All changes | includes/Core/Helper.php +128 -18 3.2.8trunk View file →
@@ -31,8 +31,9 @@
31 31 }
32 32 }
33 33 }
34 34
35 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
35 36 return apply_filters('nx_post_types', $post_types);
36 37 }
37 38
38 39 /**
@@ -66,8 +67,9 @@
66 67 }
67 68 $data[$tax_slug] = $tax;
68 69 }
69 70 }
71 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
70 72 return apply_filters('nx_loop_taxonomies', $data, $taxonomies, $post_type);
71 73 }
72 74
73 75 /**
@@ -295,13 +297,19 @@
295 297 }
296 298 return ($is_neg ? '-' : '') . $number . $suffix;
297 299 }
298 300
301 + /**
302 + * Developer log helper. Writes only when WP_DEBUG is on, and is used by the
303 + * Pro plugin's Google/YouTube integrations to report API failures.
304 + */
299 305 public static function write_log($log) {
300 306 if (true === WP_DEBUG) {
301 307 if (is_array($log) || is_object($log)) {
308 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r, WordPress.PHP.DevelopmentFunctions.error_log_error_log
302 309 error_log(print_r($log, true));
303 310 } else {
311 + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
304 312 error_log($log);
305 313 }
306 314 }
307 315 }
@@ -339,15 +347,15 @@
339 347 return array();
340 348 }
341 349 $new_data = array();
342 350 $timestamp = current_time('timestamp');
343 - $date = date('Y-m-d', $timestamp);
344 - $date_7_days_back = date('Y-m-d', strtotime($date . ' -8 days'));
351 + $date = gmdate('Y-m-d', $timestamp);
352 + $date_7_days_back = gmdate('Y-m-d', strtotime($date . ' -8 days'));
345 353 $counter_7days = 0;
346 354 $counter_todays = 0;
347 355 foreach ($data as $single_install) {
348 - date('Y-m-d', strtotime($single_install->created)) > $date_7_days_back ? $counter_7days++ : $counter_7days;
349 - date('Y-m-d', strtotime($single_install->created)) == $date ? $counter_todays++ : $counter_todays;
356 + gmdate('Y-m-d', strtotime($single_install->created)) > $date_7_days_back ? $counter_7days++ : $counter_7days;
357 + gmdate('Y-m-d', strtotime($single_install->created)) == $date ? $counter_todays++ : $counter_todays;
350 358 }
351 359 return array(
352 360 'last_week' => $counter_7days,
353 361 'today' => $counter_todays,
@@ -592,9 +600,11 @@
592 600 // Add the number of posts to the query arguments
593 601 $query_args[] = $numberposts;
594 602
595 603 // Prepare and execute the query using wpdb methods
604 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- False positive: the query is prepared via $this->wpdb->prepare(), which this sniff does not recognise, and only $wpdb->prefix table names are interpolated. Audited 2026-07-16.
596 605 $sql = $wpdb->prepare( $sql, $query_args );
606 + // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared -- False positive: the query is prepared via $this->wpdb->prepare(), which this sniff does not recognise, and only $wpdb->prefix table names are interpolated. Audited 2026-07-16.
597 607 $products = $wpdb->get_results( $sql );
598 608
599 609 if ( ! empty( $products ) ) {
600 610 // Loop through the results and build the output array
@@ -684,9 +694,9 @@
684 694 ),
685 695 'description' => array(
686 696 'type' => 'textarea',
687 697 'name' => 'description',
688 - 'label' => __('Description', 'notificationx-pro'),
698 + 'label' => __('Description', 'notificationx'),
689 699 'priority' => 30,
690 700 ),
691 701 'is_add_script' => array(
692 702 'type' => 'toggle',
@@ -708,9 +718,9 @@
708 718 ),
709 719 'script_url_pattern' => array(
710 720 'type' => 'codeviewer',
711 721 'name' => 'script_url_pattern',
712 - 'label' => __('Script', 'notificationx-pro'),
722 + 'label' => __('Script', 'notificationx'),
713 723 'priority' => 45,
714 724 ),
715 725 ];
716 726 }
@@ -735,8 +745,9 @@
735 745 $urlparts = wp_parse_url(site_url('/'));
736 746 $domain = preg_replace('/www\./i', '', $urlparts['host']);
737 747 $cookies_removed = array();
738 748 $d_domains = array('_ga', '_fbp', '_gid', '_gat', '__utma', '__utmb', '__utmc', '__utmt', '__utmz');
749 + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Reviewed for the NotificationX codebase: acceptable in this context.
739 750 $d_domains = apply_filters('gdpr_d_domains_filter', $d_domains);
740 751
741 752 // Iterate over all cookies and remove them if they match specific conditions.
742 753 if (isset($_COOKIE) && is_array($_COOKIE) && $domain) :
@@ -1095,31 +1106,93 @@
1095 1106 }
1096 1107 }
1097 1108
1098 1109 public static function nx_get_visitor_country_code() {
1110 + // Country targeting now applies to ALL notification types, so a single page
1111 + // load can evaluate several country-targeted notifications. Resolve the
1112 + // visitor's country once per request and reuse it. We key the memo on the IP
1113 + // and read it with array_key_exists (not isset) so an empty/failed result is
1114 + // remembered too and never re-triggers the header/API lookups below within the
1115 + // same request. An unknown country is always represented as an empty string.
1116 + static $memo = [];
1117 +
1099 1118 $ip = '';
1100 1119 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
1101 - $ip = $_SERVER['HTTP_CLIENT_IP'];
1120 + $ip = sanitize_text_field(wp_unslash($_SERVER['HTTP_CLIENT_IP']));
1102 1121 } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1103 - $ip = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
1122 + $ip = explode(',', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))[0];
1104 1123 } else {
1105 - $ip = $_SERVER['REMOTE_ADDR'];
1124 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1106 1125 }
1107 -
1108 - // Prevent localhost IP from erroring
1109 - if ($ip === '127.0.0.1' || $ip === '::1') {
1110 - return 'all'; // default fallback for local testing
1126 + $ip = trim($ip);
1127 +
1128 + // No usable IP (CLI/cron) or localhost: return an empty code. Callers treat an
1129 + // unknown country as "don't filter" (fail-open), so country-targeted
1130 + // notifications still show during local testing instead of silently
1131 + // disappearing. Bailing here also avoids requesting
1132 + // http://ip-api.com/json/?fields=countryCode with an empty IP, which would
1133 + // resolve to the SERVER's country and cache nothing.
1134 + if ($ip === '' || $ip === '127.0.0.1' || $ip === '::1') {
1135 + return apply_filters('nx_visitor_country_code', '', $ip);
1111 1136 }
1112 1137
1113 - $response = wp_remote_get("http://ip-api.com/json/{$ip}?fields=countryCode");
1138 + if (array_key_exists($ip, $memo)) {
1139 + return apply_filters('nx_visitor_country_code', $memo[$ip], $ip);
1140 + }
1114 1141
1115 - if (is_wp_error($response)) {
1116 - return null;
1142 + // Let a host/CDN or custom resolver short-circuit the external lookup entirely.
1143 + // e.g. Cloudflare sends CF-IPCountry; many hosts expose GEOIP_COUNTRY_CODE.
1144 + $header_country = '';
1145 + foreach (['HTTP_CF_IPCOUNTRY', 'GEOIP_COUNTRY_CODE', 'HTTP_X_COUNTRY_CODE'] as $h) {
1146 + if (!empty($_SERVER[$h])) {
1147 + $header_country = strtoupper(sanitize_text_field(wp_unslash($_SERVER[$h])));
1148 + break;
1149 + }
1117 1150 }
1151 + // 'XX'/'T1' are Cloudflare's "unknown"/Tor placeholders — ignore them.
1152 + if ($header_country !== '' && !in_array($header_country, ['XX', 'T1'], true)) {
1153 + $memo[$ip] = $header_country;
1154 + return apply_filters('nx_visitor_country_code', $header_country, $ip);
1155 + }
1118 1156
1119 - $data = json_decode(wp_remote_retrieve_body($response), true);
1157 + // Per-IP cache so we don't hit the external API on every page load. A
1158 + // transient is used deliberately: WordPress serves transients from a
1159 + // persistent object cache (Redis/Memcached) when one is available — so those
1160 + // sites add zero wp_options rows — and transparently falls back to the options
1161 + // table otherwise, so the country stays cached across requests on plain sites
1162 + // too (which is what keeps us under ip-api's 45 req/min limit). A cached empty
1163 + // string is a remembered "lookup failed" marker, distinct from a miss (false).
1164 + $cache_key = 'nx_geo_' . md5($ip);
1165 + $cached = get_transient($cache_key);
1166 + if (false !== $cached) {
1167 + $memo[$ip] = $cached;
1168 + return apply_filters('nx_visitor_country_code', $cached, $ip);
1169 + }
1120 1170
1121 - return isset($data['countryCode']) ? $data['countryCode'] : null;
1171 + $country = '';
1172 + $api_endpoint = apply_filters('nx_visitor_country_api', "http://ip-api.com/json/{$ip}?fields=countryCode", $ip);
1173 + $response = wp_remote_get($api_endpoint, ['timeout' => 3]);
1174 +
1175 + if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) === 200) {
1176 + $data = json_decode(wp_remote_retrieve_body($response), true);
1177 + if (isset($data['countryCode']) && $data['countryCode'] !== '') {
1178 + $country = $data['countryCode'];
1179 + }
1180 + }
1181 +
1182 + // Cache successes for the full TTL; negative-cache failures for a short window
1183 + // so a rate-limit/timeout (ip-api's free tier is ~45 req/min keyed by the
1184 + // SERVER IP, shared across all visitors) doesn't re-hit the API on every
1185 + // subsequent page load while it recovers.
1186 + $ttl = '' !== $country
1187 + ? (int) apply_filters('nx_visitor_country_cache_ttl', 12 * HOUR_IN_SECONDS)
1188 + : (int) apply_filters('nx_visitor_country_failed_cache_ttl', 5 * MINUTE_IN_SECONDS);
1189 + if ($ttl > 0) {
1190 + set_transient($cache_key, $country, $ttl);
1191 + }
1192 +
1193 + $memo[$ip] = $country;
1194 + return apply_filters('nx_visitor_country_code', $country, $ip);
1122 1195 }
1123 1196
1124 1197 public static function nx_get_all_country($search = '') {
1125 1198 $countries = [
@@ -1319,8 +1392,45 @@
1319 1392 return strpos(strtolower($name), $search) !== false;
1320 1393 });
1321 1394 }
1322 1395 return $countries;
1396 + }
1397 +
1398 +
1399 + /**
1400 + * Delete a design document that NotificationX itself owns.
1401 + *
1402 + * The ID reaching the callers of this method arrives in a REST payload, so
1403 + * it is attacker-controlled. Without a post-type check, any user holding
1404 + * `edit_notificationx` could pass an arbitrary ID and force-delete any post
1405 + * on the site -- pages, products, orders -- with no trash to recover from.
1406 + * Only documents of a post type NotificationX creates may be removed here.
1407 + *
1408 + * A `current_user_can( 'delete_post' )` check is deliberately NOT applied.
1409 + * These post types register with `capability_type => 'post'`, so that meta
1410 + * cap resolves to the primitive `delete_posts`. A custom role delegated only
1411 + * "Who Can Create Notification?" does not hold `delete_posts`, and gating on
1412 + * it would stop that role from removing its own designs -- breaking exactly
1413 + * the delegated workflow this boundary exists to support. Actor authority is
1414 + * already established by the route's `edit_notificationx` permission
1415 + * callback; what was missing, and what this restores, is object authority.
1416 + *
1417 + * @param int|string $post_id Candidate post ID, untrusted.
1418 + * @param string $expected_type Post type NotificationX owns.
1419 + * @return bool True when a post was deleted.
1420 + */
1421 + public static function delete_owned_post( $post_id, $expected_type ) {
1422 + $post_id = absint( $post_id );
1423 + if ( ! $post_id ) {
1424 + return false;
1425 + }
1426 +
1427 + $post = get_post( $post_id );
1428 + if ( ! $post || $expected_type !== $post->post_type ) {
1429 + return false;
1430 + }
1431 +
1432 + return (bool) wp_delete_post( $post_id, true );
1323 1433 }
1324 1434
1325 1435
1326 1436 }