PluginProbe
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar / 3.3.3
NotificationX – FOMO, Live Sales Notification, WooCommerce Sales Popup, GDPR, Social Proof, Announcement Banner & Floating Notification Bar v3.3.3
3.3.3 3.3.2 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 All 158 releases
← All changes | includes/Core/Helper.php +108 -21 3.2.12 → 3.3.3 View file →
@@ -466,20 +466,8 @@
466 466 }
467 467
468 468 return false;
469 469 }
470 - public static function remove_old_notice(){
471 - global $wp_filter;
472 - if( isset( $wp_filter['admin_notices']->callbacks[10] ) && is_array( $wp_filter['admin_notices']->callbacks[10] ) ) {
473 - foreach( $wp_filter['admin_notices']->callbacks[10] as $hash => $callbacks ) {
474 - if( is_array( $callbacks['function'] ) && ! empty( $callbacks['function'][0] ) && is_object( $callbacks['function'][0] ) && $callbacks['function'][0] instanceof \NotificationX_Licensing ) {
475 - remove_action( 'admin_notices', $hash );
476 - break;
477 - }
478 - }
479 - }
480 - }
481 -
482 470 public static function remote_get($url, $args = array(), $raw = false, $assoc = null) {
483 471 $defaults = array(
484 472 'timeout' => 20,
485 473 'redirection' => 5,
@@ -1106,8 +1094,16 @@
1106 1094 }
1107 1095 }
1108 1096
1109 1097 public static function nx_get_visitor_country_code() {
1098 + // Country targeting now applies to ALL notification types, so a single page
1099 + // load can evaluate several country-targeted notifications. Resolve the
1100 + // visitor's country once per request and reuse it. We key the memo on the IP
1101 + // and read it with array_key_exists (not isset) so an empty/failed result is
1102 + // remembered too and never re-triggers the header/API lookups below within the
1103 + // same request. An unknown country is always represented as an empty string.
1104 + static $memo = [];
1105 +
1110 1106 $ip = '';
1111 1107 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
1112 1108 $ip = sanitize_text_field(wp_unslash($_SERVER['HTTP_CLIENT_IP']));
1113 1109 } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
@@ -1114,23 +1110,77 @@
1114 1110 $ip = explode(',', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))[0];
1115 1111 } else {
1116 1112 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1117 1113 }
1118 -
1119 - // Prevent localhost IP from erroring
1120 - if ($ip === '127.0.0.1' || $ip === '::1') {
1121 - return 'all'; // default fallback for local testing
1114 + $ip = trim($ip);
1115 +
1116 + // No usable IP (CLI/cron) or localhost: return an empty code. Callers treat an
1117 + // unknown country as "don't filter" (fail-open), so country-targeted
1118 + // notifications still show during local testing instead of silently
1119 + // disappearing. Bailing here also avoids requesting
1120 + // http://ip-api.com/json/?fields=countryCode with an empty IP, which would
1121 + // resolve to the SERVER's country and cache nothing.
1122 + if ($ip === '' || $ip === '127.0.0.1' || $ip === '::1') {
1123 + return apply_filters('nx_visitor_country_code', '', $ip);
1122 1124 }
1123 1125
1124 - $response = wp_remote_get("http://ip-api.com/json/{$ip}?fields=countryCode");
1126 + if (array_key_exists($ip, $memo)) {
1127 + return apply_filters('nx_visitor_country_code', $memo[$ip], $ip);
1128 + }
1125 1129
1126 - if (is_wp_error($response)) {
1127 - return null;
1130 + // Let a host/CDN or custom resolver short-circuit the external lookup entirely.
1131 + // e.g. Cloudflare sends CF-IPCountry; many hosts expose GEOIP_COUNTRY_CODE.
1132 + $header_country = '';
1133 + foreach (['HTTP_CF_IPCOUNTRY', 'GEOIP_COUNTRY_CODE', 'HTTP_X_COUNTRY_CODE'] as $h) {
1134 + if (!empty($_SERVER[$h])) {
1135 + $header_country = strtoupper(sanitize_text_field(wp_unslash($_SERVER[$h])));
1136 + break;
1137 + }
1128 1138 }
1139 + // 'XX'/'T1' are Cloudflare's "unknown"/Tor placeholders — ignore them.
1140 + if ($header_country !== '' && !in_array($header_country, ['XX', 'T1'], true)) {
1141 + $memo[$ip] = $header_country;
1142 + return apply_filters('nx_visitor_country_code', $header_country, $ip);
1143 + }
1129 1144
1130 - $data = json_decode(wp_remote_retrieve_body($response), true);
1145 + // Per-IP cache so we don't hit the external API on every page load. A
1146 + // transient is used deliberately: WordPress serves transients from a
1147 + // persistent object cache (Redis/Memcached) when one is available — so those
1148 + // sites add zero wp_options rows — and transparently falls back to the options
1149 + // table otherwise, so the country stays cached across requests on plain sites
1150 + // too (which is what keeps us under ip-api's 45 req/min limit). A cached empty
1151 + // string is a remembered "lookup failed" marker, distinct from a miss (false).
1152 + $cache_key = 'nx_geo_' . md5($ip);
1153 + $cached = get_transient($cache_key);
1154 + if (false !== $cached) {
1155 + $memo[$ip] = $cached;
1156 + return apply_filters('nx_visitor_country_code', $cached, $ip);
1157 + }
1131 1158
1132 - return isset($data['countryCode']) ? $data['countryCode'] : null;
1159 + $country = '';
1160 + $api_endpoint = apply_filters('nx_visitor_country_api', "http://ip-api.com/json/{$ip}?fields=countryCode", $ip);
1161 + $response = wp_remote_get($api_endpoint, ['timeout' => 3]);
1162 +
1163 + if (!is_wp_error($response) && (int) wp_remote_retrieve_response_code($response) === 200) {
1164 + $data = json_decode(wp_remote_retrieve_body($response), true);
1165 + if (isset($data['countryCode']) && $data['countryCode'] !== '') {
1166 + $country = $data['countryCode'];
1167 + }
1168 + }
1169 +
1170 + // Cache successes for the full TTL; negative-cache failures for a short window
1171 + // so a rate-limit/timeout (ip-api's free tier is ~45 req/min keyed by the
1172 + // SERVER IP, shared across all visitors) doesn't re-hit the API on every
1173 + // subsequent page load while it recovers.
1174 + $ttl = '' !== $country
1175 + ? (int) apply_filters('nx_visitor_country_cache_ttl', 12 * HOUR_IN_SECONDS)
1176 + : (int) apply_filters('nx_visitor_country_failed_cache_ttl', 5 * MINUTE_IN_SECONDS);
1177 + if ($ttl > 0) {
1178 + set_transient($cache_key, $country, $ttl);
1179 + }
1180 +
1181 + $memo[$ip] = $country;
1182 + return apply_filters('nx_visitor_country_code', $country, $ip);
1133 1183 }
1134 1184
1135 1185 public static function nx_get_all_country($search = '') {
1136 1186 $countries = [
@@ -1330,8 +1380,45 @@
1330 1380 return strpos(strtolower($name), $search) !== false;
1331 1381 });
1332 1382 }
1333 1383 return $countries;
1384 + }
1385 +
1386 +
1387 + /**
1388 + * Delete a design document that NotificationX itself owns.
1389 + *
1390 + * The ID reaching the callers of this method arrives in a REST payload, so
1391 + * it is attacker-controlled. Without a post-type check, any user holding
1392 + * `edit_notificationx` could pass an arbitrary ID and force-delete any post
1393 + * on the site -- pages, products, orders -- with no trash to recover from.
1394 + * Only documents of a post type NotificationX creates may be removed here.
1395 + *
1396 + * A `current_user_can( 'delete_post' )` check is deliberately NOT applied.
1397 + * These post types register with `capability_type => 'post'`, so that meta
1398 + * cap resolves to the primitive `delete_posts`. A custom role delegated only
1399 + * "Who Can Create Notification?" does not hold `delete_posts`, and gating on
1400 + * it would stop that role from removing its own designs -- breaking exactly
1401 + * the delegated workflow this boundary exists to support. Actor authority is
1402 + * already established by the route's `edit_notificationx` permission
1403 + * callback; what was missing, and what this restores, is object authority.
1404 + *
1405 + * @param int|string $post_id Candidate post ID, untrusted.
1406 + * @param string $expected_type Post type NotificationX owns.
1407 + * @return bool True when a post was deleted.
1408 + */
1409 + public static function delete_owned_post( $post_id, $expected_type ) {
1410 + $post_id = absint( $post_id );
1411 + if ( ! $post_id ) {
1412 + return false;
1413 + }
1414 +
1415 + $post = get_post( $post_id );
1416 + if ( ! $post || $expected_type !== $post->post_type ) {
1417 + return false;
1418 + }
1419 +
1420 + return (bool) wp_delete_post( $post_id, true );
1334 1421 }
1335 1422
1336 1423
1337 1424 }