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 +108 -9 3.2.12trunk View file →
@@ -1106,8 +1106,16 @@
1106 1106 }
1107 1107 }
1108 1108
1109 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 +
1110 1118 $ip = '';
1111 1119 if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
1112 1120 $ip = sanitize_text_field(wp_unslash($_SERVER['HTTP_CLIENT_IP']));
1113 1121 } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
@@ -1114,23 +1122,77 @@
1114 1122 $ip = explode(',', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))[0];
1115 1123 } else {
1116 1124 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1117 1125 }
1118 -
1119 - // Prevent localhost IP from erroring
1120 - if ($ip === '127.0.0.1' || $ip === '::1') {
1121 - 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);
1122 1136 }
1123 1137
1124 - $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 + }
1125 1141
1126 - if (is_wp_error($response)) {
1127 - 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 + }
1128 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 + }
1129 1156
1130 - $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 + }
1131 1170
1132 - 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);
1133 1195 }
1134 1196
1135 1197 public static function nx_get_all_country($search = '') {
1136 1198 $countries = [
@@ -1330,8 +1392,45 @@
1330 1392 return strpos(strtolower($name), $search) !== false;
1331 1393 });
1332 1394 }
1333 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 );
1334 1433 }
1335 1434
1336 1435
1337 1436 }